From f9a624ef4b98689c5edc187f2b7fec5fbb155657 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 22 Sep 2026 23:09:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=A4=E9=80=9A=E5=AD=97=E6=AE=B5=E7=AE=80?= =?UTF-8?q?=E5=8C=96=20+=20=E7=85=A7=E7=89=87=E5=A4=9A=E5=9B=BE/=E8=A3=81?= =?UTF-8?q?=E5=89=AA/=E5=8F=8C=E5=AD=98=E5=82=A8=E5=90=8E=E7=AB=AF=20+=20?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL - 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次) 照片 - 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64) - MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射, 刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1 - 单张体积上限、可传张数在设置页可调 双存储后端 - 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关; OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂 - OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录), 也支持直接填 AccessKey - OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload) 媒体代理 - 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节 - 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有 - 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏 数据库 - 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑) - 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes / width / height / is_image 与 idx_storage - MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema 界面 - 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合) - 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡, 外加测试连接与角色探测 - 样式表整体重写 验证 - 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过 - 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、 26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节) --- assets/style.css | 1721 +++++++++++----------- index.php | 20 + sql/migrations/2026-09-22-storage.sql | 24 + src/Attachments.php | 195 +++ src/Controllers/ActivityController.php | 59 +- src/Controllers/AttachmentController.php | 305 ++-- src/Controllers/MediaController.php | 147 ++ src/Controllers/NodeController.php | 63 +- src/Controllers/SettingsController.php | 213 +++ src/Controllers/UserController.php | 11 +- src/Migrate.php | 119 ++ src/Mime.php | 151 ++ src/Oss.php | 396 +++++ src/Settings.php | 139 ++ src/Storage.php | 175 +++ src/helpers.php | 65 +- templates/activities/_node_card.php | 8 +- templates/activities/form.php | 6 +- templates/activities/index.php | 2 +- templates/activities/show.php | 2 +- templates/layout.php | 116 +- templates/nodes/attachments.php | 616 ++++++-- templates/nodes/form.php | 233 +-- templates/nodes/show.php | 65 +- templates/settings/index.php | 310 ++++ tools/migrate.php | 61 + 26 files changed, 3894 insertions(+), 1328 deletions(-) create mode 100644 sql/migrations/2026-09-22-storage.sql create mode 100644 src/Attachments.php create mode 100644 src/Controllers/MediaController.php create mode 100644 src/Controllers/SettingsController.php create mode 100644 src/Migrate.php create mode 100644 src/Mime.php create mode 100644 src/Oss.php create mode 100644 src/Settings.php create mode 100644 src/Storage.php create mode 100644 templates/settings/index.php create mode 100644 tools/migrate.php diff --git a/assets/style.css b/assets/style.css index f45f8f3..46b60ea 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1,968 +1,931 @@ +/* ============================================================================ + 家庭出行记录 · 样式表 + ---------------------------------------------------------------------------- + 组织方式:设计令牌 → 基础 → 布局 → 组件 → 页面 → 响应式 + 约定: + · 颜色/间距/圆角一律走 CSS 变量,改主题只动 :root + · 不写 !important;靠顺序与具体度控制 + · 组件类名与模板一一对应,改名前先搜模板 + ========================================================================== */ + +/* ── 1. 设计令牌 ─────────────────────────────────────────────────────────── */ +:root { + --bg: #f5f6f8; + --bg-soft: #eceef2; + --surface: #ffffff; + --surface-2: #fafbfc; + --border: #e3e6ec; + --border-strong: #cfd5df; + + --text: #1c2029; + --text-soft: #5b6371; + --text-mute: #8b93a1; + + --accent: #3d6bf5; + --accent-dark: #2f56cf; + --accent-soft: #eaf0ff; + --accent-ring: rgba(61, 107, 245, .18); + + --success: #12855a; + --success-soft: #e4f6ee; + --warn: #b7791f; + --warn-soft: #fdf3e0; + --danger: #d63b3f; + --danger-soft: #fdecec; + + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 16px; + + --shadow-xs: 0 1px 2px rgba(22, 28, 45, .06); + --shadow-sm: 0 2px 8px rgba(22, 28, 45, .07); + --shadow-md: 0 8px 24px rgba(22, 28, 45, .10); + --shadow-lg: 0 18px 48px rgba(22, 28, 45, .16); + + --container: 1080px; + --topbar-h: 60px; +} + * { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + body { margin: 0; - font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; - background: #f5f5f7; - color: #1d1d1f; - line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "Segoe UI", Roboto, sans-serif; + font-size: 15px; + line-height: 1.62; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; } -a { color: #007aff; text-decoration: none; } -a:hover { text-decoration: underline; } +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-dark); text-decoration: underline; } + +h1, h2, h3, h4 { margin: 0 0 .4em; line-height: 1.3; font-weight: 650; letter-spacing: -.01em; } +h1 { font-size: 22px; } +h2 { font-size: 17px; } +h3 { font-size: 15px; } +p { margin: 0 0 .8em; } +hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } + +/* 工具类 */ +.muted { color: var(--text-mute); font-size: 13px; } +.danger { color: var(--danger); } +.meta { color: var(--text-mute); font-size: 12.5px; } +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.section-title { + font-size: 15px; font-weight: 650; margin: 28px 0 12px; + display: flex; align-items: center; gap: 8px; +} +.section-title::before { + content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--accent); +} + +/* ── 2. 顶栏 ─────────────────────────────────────────────────────────────── */ .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; + position: sticky; top: 0; z-index: 40; + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + min-height: var(--topbar-h); + padding: 8px max(14px, calc((100vw - var(--container)) / 2)); + background: rgba(255, 255, 255, .88); + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--border); } -.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; } +.brand { + display: inline-flex; align-items: center; gap: 8px; + font-weight: 680; font-size: 16px; color: var(--text); letter-spacing: -.01em; +} +.brand:hover { text-decoration: none; color: var(--text); } +.brand-mark { font-size: 18px; line-height: 1; } + +.topbar-right { display: flex; align-items: center; gap: 10px; } + +/* ── 3. 右上角下拉菜单 ───────────────────────────────────────────────────── */ +.menu { position: relative; } + +.menu-trigger { + list-style: none; + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 10px 5px 5px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + cursor: pointer; + user-select: none; + transition: border-color .15s, box-shadow .15s, background .15s; +} +.menu-trigger::-webkit-details-marker { display: none; } +.menu-trigger:hover { border-color: var(--border-strong); box-shadow: var(--shadow-xs); } +.menu[open] > .menu-trigger { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); + background: var(--surface); +} +.menu-name { + font-size: 14px; font-weight: 550; + max-width: 7em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.menu-caret { font-size: 10px; color: var(--text-mute); transition: transform .18s; } +.menu[open] .menu-caret { transform: rotate(180deg); } + +.menu-panel { + position: absolute; right: 0; top: calc(100% + 8px); + min-width: 216px; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + animation: menu-in .14s ease-out; + z-index: 50; +} +@keyframes menu-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.menu-head { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; } +.menu-head-name { font-weight: 620; font-size: 14px; } +.menu-head-role { font-size: 12px; color: var(--text-mute); } + +.menu-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: var(--text); font-size: 14px; text-decoration: none; + transition: background .12s; +} +.menu-item:hover { background: var(--bg-soft); text-decoration: none; color: var(--text); } +.menu-icon { width: 18px; text-align: center; font-size: 15px; opacity: .9; } +.menu-sep { + font-size: 11px; color: var(--text-mute); + padding: 10px 10px 4px; letter-spacing: .06em; +} +.menu-item-danger { color: var(--danger); } +.menu-item-danger:hover { background: var(--danger-soft); color: var(--danger); } + +/* ── 4. 容器与页头 ───────────────────────────────────────────────────────── */ +.container { + max-width: var(--container); + margin: 0 auto; + padding: 24px max(14px, calc((100vw - var(--container)) / 2)) 64px; +} +.container-guest { + max-width: 420px; + display: flex; align-items: center; justify-content: center; + min-height: 100vh; padding: 24px 16px; +} .page-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 24px; - gap: 16px; + display: flex; align-items: flex-start; justify-content: space-between; + gap: 16px; flex-wrap: wrap; + margin-bottom: 20px; } -.page-header h1 { margin: 0 0 4px; font-size: 24px; } +.page-header h1 { margin-bottom: 3px; } .page-header .muted { margin: 0; } +.page-header > div:first-child { min-width: 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; +.footer { + text-align: center; color: var(--text-mute); font-size: 12.5px; + padding: 20px 16px 36px; } -.btn:hover, .btn-primary:hover { background: #f0f0f2; text-decoration: none; } -.btn-primary { background: #007aff; color: #fff; border-color: #007aff; } -.btn-primary:hover { background: #0066d6; } +/* ── 5. 按钮 ─────────────────────────────────────────────────────────────── */ +.btn, .btn-primary, .btn-danger, .btn-sm, .btn-primary-sm { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px 15px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 14px; font-family: inherit; font-weight: 550; line-height: 1.4; + cursor: pointer; text-decoration: none; white-space: nowrap; + transition: background .15s, border-color .15s, box-shadow .15s, transform .06s; +} +.btn:hover, .btn-sm:hover { + background: var(--surface-2); border-color: var(--border-strong); + text-decoration: none; color: var(--text); +} +.btn:active, .btn-primary:active, .btn-danger:active { transform: translateY(1px); } + +.btn-primary, .btn-primary-sm { + background: var(--accent); border-color: var(--accent); color: #fff; + box-shadow: var(--shadow-xs); +} +.btn-primary:hover, .btn-primary-sm:hover { + background: var(--accent-dark); border-color: var(--accent-dark); + color: #fff; text-decoration: none; +} + +.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } +.btn-danger:hover { background: #bd3135; border-color: #bd3135; color: #fff; text-decoration: none; } + +.btn-sm, .btn-primary-sm { padding: 5px 11px; font-size: 13px; } +.topbar-new { flex: none; } + +/* ── 6. 提示 / 空态 / 标签 ───────────────────────────────────────────────── */ .alert { - background: #fff3cd; - border: 1px solid #ffe08a; - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 16px; - color: #664d03; + display: flex; align-items: flex-start; gap: 8px; + padding: 11px 14px; margin-bottom: 18px; + background: var(--accent-soft); + border: 1px solid #cddbff; + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + color: #1f3d8f; font-size: 14px; } .empty { - text-align: center; - padding: 60px 20px; - background: #fff; - border-radius: 12px; - border: 1px solid #e5e5e7; + text-align: center; color: var(--text-mute); + padding: 40px 20px; font-size: 14px; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); } -.empty p { margin: 0 0 16px; color: #86868b; } +.empty p { margin: 0; } +.tag-default, .tag-self, .tag-temp, .status-tag, .mode-tag, .veh-tag, .role-tag, .node-type { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 999px; + font-size: 12px; font-weight: 550; line-height: 1.7; white-space: nowrap; +} +.tag-default { background: var(--bg-soft); color: var(--text-soft); } +.tag-self { background: var(--success-soft); color: var(--success); } +.tag-temp { background: var(--warn-soft); color: var(--warn); } +.node-type { background: var(--accent-soft); color: var(--accent-dark); } + +.status-planned { background: var(--bg-soft); color: var(--text-soft); } +.status-ongoing { background: var(--accent-soft); color: var(--accent-dark); } +.status-completed { background: var(--success-soft); color: var(--success); } +.status-cancelled { background: var(--danger-soft); color: var(--danger); } + +/* 账号状态(用户列表用文字 + 颜色,不是胶囊标签) */ +.status-active { color: var(--success); font-size: 13px; font-weight: 550; } +.status-inactive { color: var(--text-mute); font-size: 13px; } + +.mode-tag { background: var(--bg-soft); color: var(--text-soft); } +.mode-drive_own, .mode-drive_rental { background: #e9f2ff; color: #1e5bb8; } +.mode-train { background: #eaf1ff; color: #2f56cf; } +.mode-plane { background: #e8f6fb; color: #17708f; } +.mode-ship { background: #e6f7f4; color: #106b60; } +.mode-walk, .mode-bike { background: var(--success-soft); color: var(--success); } +.mode-subway, .mode-bus, .mode-taxi { background: var(--warn-soft); color: var(--warn); } + +.veh-own { background: var(--bg-soft); color: var(--text-soft); } +.veh-rental { background: var(--warn-soft); color: var(--warn); } + +.role-admin { background: #f0e9ff; color: #6b3fc4; } +.role-user { background: var(--bg-soft); color: var(--text-soft); } + +.money-over { color: var(--danger); } +.money-under { color: var(--success); } + +/* ── 7. 卡片 ─────────────────────────────────────────────────────────────── */ .card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(248px, 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; +.card { + display: flex; flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + transition: transform .16s, box-shadow .16s, border-color .16s; } -@media (max-width: 720px) { - .timeline-grid { grid-template-columns: 1fr; } +.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-sm); border-color: var(--border-strong); } + +.card-cover { position: relative; aspect-ratio: 16 / 9; background: var(--bg-soft); overflow: hidden; } +.card-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.card-cover-placeholder, .cover-placeholder { + display: flex; align-items: center; justify-content: center; + height: 100%; min-height: 96px; + background: linear-gradient(135deg, #eef2fb, #e6ebf7); + color: #9aa6bd; font-size: 30px; } -.timeline-col h2 { font-size: 16px; margin: 0 0 12px; color: #555; } +.card-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; } +.card-body h3 { margin: 0; font-size: 15.5px; } +.card-body h3 a { color: var(--text); } +.card-body h3 a:hover { color: var(--accent); text-decoration: none; } + +.activity-cover { position: relative; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 20px; } +.activity-cover img { width: 100%; max-height: 320px; object-fit: cover; display: block; } +.activity-cover .cover-placeholder { min-height: 150px; } + +/* ── 8. 时间轴与节点卡片 ─────────────────────────────────────────────────── */ +.timeline-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; } +.timeline-col > h2 { margin-bottom: 12px; } .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; + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border-strong); + border-radius: var(--radius); + padding: 13px 15px; + margin-bottom: 12px; + transition: box-shadow .15s, border-color .15s; } +.node-card:hover { box-shadow: var(--shadow-sm); } +.node-plan { border-left-color: #9db4f0; } +.node-reality { border-left-color: var(--success); } +.node-cancelled { opacity: .62; } -.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 { +.node-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.node-title { font-weight: 620; font-size: 15px; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.node-icon { font-size: 15px; } +.node-time { color: var(--text-mute); font-size: 12.5px; margin-top: 2px; } + +.node-loc { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + font-size: 13px; color: var(--text-soft); margin-top: 6px; +} +.loc-start, .loc-end { display: inline-flex; align-items: center; gap: 4px; } +.loc-arrow { color: var(--text-mute); } + +.node-transport { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 7px; } +.node-cancel-reason { font-size: 12.5px; color: var(--danger); margin-top: 6px; } + +.node-actions { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin-top: 10px; padding-top: 9px; border-top: 1px dashed var(--border); +} +.node-action-link, .node-action-strong { + font-size: 12.5px; color: var(--accent); padding: 3px 7px; border-radius: 6px; +} +.node-action-link:hover, .node-action-strong:hover { background: var(--accent-soft); text-decoration: none; } +.node-action-strong { font-weight: 600; } +.node-action-note { font-size: 12.5px; color: var(--text-mute); } +.node-action-btn, .node-action-form button { + font-size: 12.5px; padding: 3px 9px; border-radius: 6px; + border: 1px solid var(--border); background: var(--surface); color: var(--text-soft); + cursor: pointer; font-family: inherit; +} +.node-action-btn:hover, .node-action-form button:hover { border-color: var(--border-strong); color: var(--text); } +.node-action-form { display: inline; } + +/* ── 9. 表单 ─────────────────────────────────────────────────────────────── */ +.form { max-width: 720px; } +.form-plain { max-width: none; } + +.form-row { margin-bottom: 15px; } +.form-row > label { + display: block; margin-bottom: 5px; + font-size: 13px; font-weight: 600; color: var(--text-soft); +} +.form-row .muted { margin-top: 4px; } + +input[type="text"], input[type="password"], input[type="number"], input[type="datetime-local"], +input[type="date"], input[type="email"], input[type="tel"], input[type="url"], input[type="search"], +select, textarea { width: 100%; - padding: 9px 12px; - border: 1px solid #d2d2d7; - border-radius: 8px; - font-size: 14px; - font-family: inherit; - background: #fff; + padding: 9px 11px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 14px; font-family: inherit; line-height: 1.5; + transition: border-color .15s, box-shadow .15s; } -.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); +input:focus, select:focus, textarea:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } -.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; } +input:disabled, select:disabled, textarea:disabled { background: var(--bg-soft); color: var(--text-mute); } +input[type="file"] { font-size: 13px; padding: 6px 0; border: 0; background: none; width: auto; } +textarea { resize: vertical; min-height: 84px; } +select { cursor: pointer; } -.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; +.form-actions { + display: flex; align-items: center; gap: 10px; + margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); } -.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; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 15px 16px 4px; + margin: 0 0 16px; + background: var(--surface-2); } -.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; +.fieldset > legend { + padding: 0 7px; font-size: 13px; font-weight: 620; color: var(--text-soft); } -.radio-group { - display: flex; - gap: 20px; +.inline-panel { + padding: 14px 16px; margin: 12px 0; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); } +.panel { + padding: 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 18px; +} + +.radio-group { display: flex; flex-wrap: wrap; gap: 8px; } .radio-label { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; - font-size: 14px; - margin: 0; - font-weight: normal; + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 11px; border: 1px solid var(--border-strong); border-radius: 999px; + font-size: 13px; cursor: pointer; background: var(--surface); } +.radio-label:hover { border-color: var(--accent); } .radio-label input { margin: 0; } +.check-grid { display: flex; flex-wrap: wrap; gap: 8px; } .checkbox-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 14px; - margin: 0; - font-weight: normal; + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 11px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + font-size: 13px; cursor: pointer; background: var(--surface); max-width: 100%; } +.checkbox-label:hover { border-color: var(--accent); background: var(--accent-soft); } .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; -} +.checkbox-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* ====== 高德地址自动补全(自定义下拉) ====== */ +/* 高德地址联想下拉 */ .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; + position: absolute; left: 0; right: 0; top: calc(100% + 4px); + max-height: 260px; overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + z-index: 30; } -.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; } +.amap-dropdown-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--border); } +.amap-dropdown-item:last-child { border-bottom: 0; } +.amap-dropdown-item:hover, .amap-dropdown-item.active { background: var(--accent-soft); } +.amap-dropdown-name { font-size: 13.5px; } +.amap-dropdown-sub { font-size: 12px; color: var(--text-mute); } -.node-attachment-link { - font-size: 12px; - color: #007aff; - text-decoration: none; +/* emoji 选择 */ +.emoji-picker { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.emoji-btn { + width: 34px; height: 34px; font-size: 17px; line-height: 1; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface); cursor: pointer; padding: 0; } -.node-attachment-link:hover { text-decoration: underline; } +.emoji-btn:hover { border-color: var(--accent); background: var(--accent-soft); } +/* 表格 */ +.data-table { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--surface); } +.data-table th, .data-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); } +.data-table th { font-size: 12.5px; font-weight: 620; color: var(--text-mute); background: var(--surface-2); white-space: nowrap; } +.data-table tr:last-child td { border-bottom: 0; } +.data-table tbody tr:hover { background: var(--surface-2); } +.data-table td.actions { white-space: nowrap; } +.data-table td.actions a, .data-table td.actions button { margin-right: 8px; } + +/* ── 10. 详情页 ──────────────────────────────────────────────────────────── */ +.detail-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px 22px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.detail-item { min-width: 0; } +.detail-item-wide { grid-column: 1 / -1; } +.detail-label { font-size: 12px; color: var(--text-mute); margin-bottom: 2px; } +.detail-value { font-size: 14px; word-break: break-word; } + +/* 花销汇总条 */ +.activity-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } +.activity-stat { + flex: 1 1 140px; + padding: 11px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; flex-direction: column; gap: 1px; +} +.activity-stat .detail-label { margin: 0; } +.activity-stat strong { font-size: 16px; } + +/* ── 11. 评论 ────────────────────────────────────────────────────────────── */ +.comment-thread { margin-top: 8px; } +.comment-list { display: flex; flex-direction: column; gap: 14px; } +.comment-item { + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.comment-child { + margin-left: 22px; + background: var(--surface-2); + border-left: 2px solid var(--accent-soft); +} +.comment-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 5px; } +.comment-author { font-weight: 620; font-size: 13.5px; } +.comment-time { font-size: 12px; color: var(--text-mute); } +.comment-body { font-size: 14px; white-space: pre-wrap; word-break: break-word; } +.comment-replies { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; } +.comment-del-form { display: inline; margin-left: auto; } +.comment-del { + border: 0; background: none; color: var(--text-mute); + font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; +} +.comment-del:hover { background: var(--danger-soft); color: var(--danger); } + +.comment-form { margin-top: 14px; } +.comment-form-inline { margin-top: 8px; } +.comment-form textarea, .comment-form-inline textarea { min-height: 68px; } +.comment-reply-box { margin-top: 10px; } + +/* ── 12. 参与人 ──────────────────────────────────────────────────────────── */ +.member-list { display: flex; flex-direction: column; gap: 10px; } +.member-row { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 10px 13px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.member-name { font-weight: 600; font-size: 14px; } +.member-role { display: inline-flex; flex-wrap: wrap; gap: 5px; } +.member-emotion { font-size: 17px; line-height: 1; } +.member-checkin { font-size: 12.5px; color: var(--text-mute); } +.member-del-form { display: inline; margin-left: auto; } +.member-del-form button { + border: 0; background: none; color: var(--text-mute); + font-size: 15px; cursor: pointer; padding: 2px 6px; border-radius: 6px; +} +.member-del-form button:hover { background: var(--danger-soft); color: var(--danger); } + +.emotion-grid { display: flex; flex-wrap: wrap; gap: 6px; } +.emotion-option { + display: inline-flex; align-items: center; gap: 5px; + padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 999px; + cursor: pointer; font-size: 14px; background: var(--surface); +} +.emotion-option:hover { border-color: var(--accent); } +.emotion-option input { margin: 0; } +.emotion-face { font-size: 16px; line-height: 1; } +.emotion-text { font-size: 12.5px; color: var(--text-soft); } + +/* ── 13. 花销 ────────────────────────────────────────────────────────────── */ +.expense-summary { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } +.expense-stat { + flex: 1 1 130px; + padding: 11px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.expense-stat > div:first-child { font-size: 12px; color: var(--text-mute); } +.expense-stat strong { font-size: 16px; } + +.expense-list { display: flex; flex-direction: column; gap: 10px; } +.expense-row { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; + padding: 11px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.expense-main { min-width: 0; } +.expense-cat { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.expense-desc { font-size: 13px; color: var(--text-soft); margin-top: 2px; } +.expense-sub { font-size: 12.5px; color: var(--text-mute); margin-top: 3px; } +.expense-amount { font-size: 15px; font-weight: 650; white-space: nowrap; } +.expense-kind { font-size: 11.5px; padding: 1px 7px; border-radius: 999px; font-weight: 550; } +.expense-kind-budget { background: var(--bg-soft); color: var(--text-soft); } +.expense-kind-actual { background: var(--success-soft); color: var(--success); } + +/* ── 14. 附件与照片 ──────────────────────────────────────────────────────── */ .drop-zone { - border: 2px dashed #d2d2d7; - border-radius: 12px; - padding: 32px 20px; + border: 2px dashed var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + padding: 22px 16px; text-align: center; cursor: pointer; - transition: 0.15s; - background: #fafafa; + transition: border-color .15s, background .15s; } -.drop-zone:hover, .drop-zone.dragging { - border-color: #007aff; - background: #f0f7ff; -} -.drop-zone-inner { color: #555; } +.drop-zone:hover, .drop-zone.dragging { border-color: var(--accent); background: var(--accent-soft); } +.drop-zone-inner { color: var(--text-soft); } +.drop-zone-inner .muted { margin-top: 4px; } -.file-list { - margin-top: 12px; - display: flex; - flex-direction: column; - gap: 6px; -} +.file-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; } .file-row { - padding: 6px 12px; - background: #f0f0f2; - border-radius: 6px; - font-size: 13px; - color: #333; + font-size: 13px; color: var(--text-soft); + padding: 7px 11px; background: var(--surface-2); + border: 1px solid var(--border); border-radius: var(--radius-sm); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.attachment-grid { +/* 待上传照片的暂存区 */ +.photo-stage { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 14px; } +.photo-item { + width: 132px; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; display: flex; flex-direction: column; +} +.photo-thumb { width: 100%; height: 96px; object-fit: cover; background: var(--bg-soft); display: block; } +.photo-info { padding: 6px 8px 0; font-size: 11.5px; color: var(--text-mute); } +.photo-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-soft); } +.photo-actions { display: flex; gap: 4px; padding: 6px 8px 8px; } +.photo-actions button { + flex: 1; font-size: 11.5px; padding: 4px; cursor: pointer; + border: 1px solid var(--border); border-radius: 6px; + background: var(--surface); color: var(--text-soft); font-family: inherit; +} +.photo-actions button:hover { border-color: var(--accent); color: var(--accent); } +.photo-badge { + margin: 6px 8px 0; align-self: flex-start; + font-size: 11px; padding: 1px 6px; border-radius: 999px; + background: var(--success-soft); color: var(--success); +} + +/* 已上传的照片墙 */ +.photo-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; +} +.photo-grid-sm { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } +.photo-card { + position: relative; display: block; + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; background: var(--surface); + transition: box-shadow .15s, transform .15s; +} +.photo-card:hover { box-shadow: var(--shadow-sm); transform: translateY(-2px); } +.photo-card > a { display: block; color: inherit; } +.photo-card > a:hover { text-decoration: none; } +.photo-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: var(--bg-soft); } + +/* 存储后端标记(本地 / OSS),压在缩略图左上角 */ +.photo-tag { + position: absolute; top: 6px; left: 6px; + font-size: 10.5px; padding: 1px 6px; border-radius: 999px; + background: rgba(20, 24, 34, .6); color: #fff; letter-spacing: .3px; +} +.photo-tag-oss { background: rgba(232, 122, 45, .92); } + +.photo-cap { padding: 8px 10px 10px; font-size: 12px; color: var(--text-mute); } +.photo-cap-name { + display: block; font-size: 12.5px; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.photo-cap-sub { + display: block; margin-top: 2px; font-size: 11.5px; color: var(--text-mute); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.photo-cap-links { display: block; margin-top: 7px; } +.photo-cap-links a { font-size: 12px; margin-right: 12px; } + +.photo-del-form { position: absolute; top: 6px; right: 6px; margin: 0; opacity: 0; transition: opacity .15s; } +.photo-card:hover .photo-del-form, .photo-del-form:focus-within { opacity: 1; } +.photo-del { + width: 26px; height: 26px; border-radius: 50%; + border: 0; cursor: pointer; line-height: 1; + background: rgba(20, 24, 34, .55); color: #fff; font-size: 15px; + display: flex; align-items: center; justify-content: center; +} +.photo-del:hover { background: var(--danger); } + +/* 详情页上的小照片墙 */ +.photo-more { + display: flex; flex-direction: column; align-items: center; justify-content: center; + aspect-ratio: 4 / 3; border: 1px dashed var(--border-strong); + border-radius: var(--radius); background: var(--surface-2); + color: var(--text-soft); font-size: 18px; font-weight: 620; +} +.photo-more span { font-size: 11.5px; font-weight: 400; color: var(--text-mute); } +.photo-more:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } +.photo-bar { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; } + +/* 通用附件卡(非图片也用) */ +.attachment-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; } .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; + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; background: var(--surface); display: flex; flex-direction: column; } +.attachment-link { display: block; color: inherit; } .attachment-link:hover { text-decoration: none; } -.attachment-thumb { - width: 100%; - height: 100%; - object-fit: cover; -} +.attachment-thumb { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: var(--bg-soft); } .attachment-icon { - font-size: 48px; - color: #86868b; -} -.attachment-meta { - padding: 8px 10px; - position: relative; + display: flex; align-items: center; justify-content: center; + height: 96px; font-size: 30px; background: var(--bg-soft); } +.attachment-meta { padding: 9px 11px; display: flex; align-items: center; gap: 6px; } .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; + font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.attachment-sub { font-size: 11.5px; color: var(--text-mute); } .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; + border: 0; background: none; color: var(--text-mute); + font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; flex: none; } -.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; } +.attachment-delete:hover { background: var(--danger-soft); color: var(--danger); } -/* ====== 节点详情:基础信息 ====== */ -.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; +/* ── 15. 弹窗与裁剪 ──────────────────────────────────────────────────────── */ +.modal { + position: fixed; inset: 0; z-index: 100; + display: none; align-items: center; justify-content: center; + padding: 20px; +} +.modal.open { display: flex; } +.modal-backdrop { position: absolute; inset: 0; background: rgba(18, 22, 32, .58); } +.modal-content { + position: relative; z-index: 1; + width: 100%; max-width: 460px; + max-height: calc(100vh - 40px); overflow: auto; + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 20px; +} +.modal-content-wide { max-width: 720px; } +.modal-actions { + display: flex; justify-content: flex-end; gap: 10px; + margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); } -.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; +.crop-container { width: 100%; max-height: 60vh; overflow: hidden; background: #111; border-radius: var(--radius-sm); } +.crop-container-wide { max-height: 66vh; } +.crop-container img { max-width: 100%; display: block; } + +.avatar-preview { + width: 84px; height: 84px; border-radius: 50%; + object-fit: cover; background: var(--bg-soft); border: 1px solid var(--border); +} +.cover-preview { + width: 100%; aspect-ratio: 16 / 9; height: auto; + border-radius: var(--radius); object-fit: cover; background: var(--bg-soft); + border: 1px solid var(--border); +} +.avatar-placeholder, .avatar-upload { + display: flex; align-items: center; justify-content: center; + width: 84px; height: 84px; border-radius: 50%; + background: var(--bg-soft); color: var(--text-mute); font-size: 26px; +} +.avatar-upload, .cover-upload { + border: 2px dashed var(--border-strong); cursor: pointer; + transition: border-color .15s, background .15s; +} +.avatar-upload:hover, .cover-upload:hover { border-color: var(--accent); background: var(--accent-soft); } +.cover-upload { + display: flex; align-items: center; justify-content: center; + width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius); + color: var(--text-mute); font-size: 14px; +} +.avatar-upload-actions, .cover-upload-actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; } + +/* ── 16. 头像与用户 ──────────────────────────────────────────────────────── */ +.avatar-xs { + width: 26px; height: 26px; border-radius: 50%; + object-fit: cover; flex: none; + display: inline-flex; align-items: center; justify-content: center; +} +.avatar-initial { background: var(--accent); color: #fff; font-size: 12px; font-weight: 620; } +.avatar-sm { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: none; } +.user-cell { display: flex; align-items: center; gap: 9px; } +.user-info { display: inline-flex; align-items: center; gap: 8px; } + +/* ── 17. 登录页 ──────────────────────────────────────────────────────────── */ +.login-wrapper { width: 100%; } +.login-form { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: 28px 24px; +} +.login-form h1 { font-size: 19px; text-align: center; margin-bottom: 4px; } +.login-form > p { text-align: center; margin-bottom: 20px; } + +/* ── 18. 存储设置页 ──────────────────────────────────────────────────────── */ +.settings-status { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; } +.settings-chip { + flex: 1 1 190px; + padding: 11px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); font-size: 13px; - color: #555; } -.activity-stat { white-space: nowrap; } -.money-over { color: #b3261e; } -.money-under { color: #1a7f37; } -.node-action-strong { font-weight: 600; } +.settings-chip.ok { border-left-color: var(--success); } +.settings-chip.warn { border-left-color: var(--warn); } +.settings-chip > div:first-child { font-size: 12px; color: var(--text-mute); } +.settings-chip strong { font-size: 14px; } -/* ====== 评论 ====== */ -.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; } +.settings-hint { font-size: 12.5px; color: var(--text-mute); margin-top: 5px; line-height: 1.6; } +.settings-hint code { background: var(--bg-soft); padding: 1px 5px; border-radius: 4px; font-size: 12px; word-break: break-all; } -/* ====== 参与人 ====== */ -.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; +.oss-fields { display: none; } +.oss-fields.show { display: block; } +.test-result { + margin-top: 12px; padding: 11px 13px; border-radius: var(--radius-sm); + font-size: 13px; display: none; white-space: pre-wrap; word-break: break-word; } -.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; +.test-result.show { display: block; } +.test-result.ok { background: var(--success-soft); color: var(--success); border: 1px solid #b9e4d1; } +.test-result.bad { background: var(--danger-soft); color: var(--danger); border: 1px solid #f3c9ca; } +.role-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; } +.role-chip { + font-size: 12.5px; padding: 3px 10px; border-radius: 999px; + border: 1px solid var(--border-strong); background: var(--surface); + cursor: pointer; font-family: inherit; color: var(--text-soft); } -.member-emotion { font-size: 13px; color: #b26a00; } -.member-checkin { font-size: 12px; color: #1a7f37; } -.member-del-form { margin-left: auto; } +.role-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } -/* 情绪单选:只显示表情,文字用 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; } +/* ── 19. 形态开关(自有车 / 租车 等按需显示的字段) ─────────────────────── */ +.own-only, .rental-only, .mode-field { display: none; } -.check-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 6px 12px; +/* ── 20. 附件页与表单补充 ───────────────────────────────────────────────── */ + +/* 表单里的小字说明 */ +.form-hint { font-size: 12.5px; color: var(--text-mute); margin-top: 5px; line-height: 1.65; } +.form-hint code, .settings-hint code, .alert code { + background: var(--bg-soft); padding: 1px 5px; border-radius: 4px; + font-size: 12px; word-break: break-all; +} +.alert-warn { background: var(--warn-soft); color: var(--warn); border-color: #ecd9b4; } +.text-danger { color: var(--danger); } +.req { color: var(--danger); font-weight: 620; } +.inline-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.inline-row > input { flex: 1 1 220px; min-width: 0; } + +/* 上传区 */ +.drop-zone-icon { font-size: 30px; line-height: 1.1; } +.drop-zone-title { margin: 6px 0 0; font-size: 14.5px; color: var(--text); } +.drop-zone-hint { font-size: 12.5px; margin: 4px 0 0; } + +/* 待上传暂存区 */ +.stage-head { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-top: 18px; flex-wrap: wrap; +} +.stage-count { font-size: 13px; color: var(--text-soft); } +.stage-tools { display: flex; gap: 8px; } +.photo-thumb-icon { + display: flex; align-items: center; justify-content: center; + font-size: 28px; height: 96px; background: var(--bg-soft); +} +.photo-badge-plain { background: var(--bg-soft); color: var(--text-soft); } +.photo-actions button[disabled] { opacity: .45; cursor: not-allowed; } + +/* 上传进度 */ +.upload-progress { margin-top: 14px; } +.upload-track { + height: 8px; border-radius: 999px; background: var(--bg-soft); overflow: hidden; +} +.upload-bar { + height: 100%; width: 0; border-radius: 999px; + background: var(--accent); transition: width .2s ease; +} +.upload-text { margin-top: 6px; font-size: 12.5px; color: var(--text-mute); } + +/* 弹窗标题 */ +.modal-title { font-size: 16px; margin: 0 0 4px; } +.modal-hint { font-size: 12.5px; color: var(--text-mute); margin: 0 0 12px; } + +/* ── 21. 响应式 ──────────────────────────────────────────────────────────── */ +@media (max-width: 820px) { + .timeline-grid { grid-template-columns: 1fr; } + .card-grid { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); } } -/* ====== 花销 ====== */ -.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; +@media (max-width: 640px) { + body { font-size: 14.5px; } + .container { padding: 16px 14px 52px; } + .container-guest { padding: 20px 14px; } + .topbar { padding: 8px 14px; } + .brand-text { display: none; } + .menu-name { display: none; } + .menu-trigger { padding: 4px; gap: 4px; } + .two-col { grid-template-columns: 1fr; } + .card-grid, .photo-grid, .attachment-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } + .detail-grid { grid-template-columns: 1fr; } + .page-header { flex-direction: column; align-items: stretch; } + .page-header .btn, .page-header .btn-primary { align-self: flex-start; } + .comment-child { margin-left: 10px; } + .modal { padding: 12px; } + .modal-content { padding: 16px; } + .topbar-new { padding: 5px 9px; font-size: 12.5px; } + .expense-row { flex-direction: column; } + .expense-amount { align-self: flex-end; } + .photo-item { width: calc(50% - 6px); } + .settings-chip { flex: 1 1 100%; } + .inline-row { align-items: stretch; } } diff --git a/index.php b/index.php index 8161c50..32ef856 100644 --- a/index.php +++ b/index.php @@ -8,6 +8,12 @@ require __DIR__ . '/src/Comments.php'; require __DIR__ . '/src/Members.php'; require __DIR__ . '/src/Expenses.php'; require __DIR__ . '/src/Access.php'; +require __DIR__ . '/src/Migrate.php'; +require __DIR__ . '/src/Mime.php'; +require __DIR__ . '/src/Settings.php'; +require __DIR__ . '/src/Oss.php'; +require __DIR__ . '/src/Storage.php'; +require __DIR__ . '/src/Attachments.php'; require __DIR__ . '/src/Controllers/AuthController.php'; require __DIR__ . '/src/Controllers/AttachmentController.php'; require __DIR__ . '/src/Controllers/ActivityController.php'; @@ -20,6 +26,8 @@ require __DIR__ . '/src/Controllers/RoleTagController.php'; require __DIR__ . '/src/Controllers/MemberController.php'; require __DIR__ . '/src/Controllers/CommentController.php'; require __DIR__ . '/src/Controllers/ExpenseController.php'; +require __DIR__ . '/src/Controllers/MediaController.php'; +require __DIR__ . '/src/Controllers/SettingsController.php'; use App\Auth; use App\Controllers\AuthController; @@ -34,6 +42,8 @@ use App\Controllers\RoleTagController; use App\Controllers\MemberController; use App\Controllers\CommentController; use App\Controllers\ExpenseController; +use App\Controllers\MediaController; +use App\Controllers\SettingsController; session_start(); date_default_timezone_set('Asia/Shanghai'); @@ -69,6 +79,16 @@ $map = [ 'attachments/destroy' => [AttachmentController::class, 'destroy'], 'activities/uploadCover' => [ActivityController::class, 'uploadCover'], + // 媒体代理(照片/封面都经这里输出,不暴露 OSS 地址) + 'media/show' => [MediaController::class, 'show'], + 'media/cover' => [MediaController::class, 'cover'], + + // 存储设置(仅管理员) + 'settings/index' => [SettingsController::class, 'index'], + 'settings/save' => [SettingsController::class, 'save'], + 'settings/test' => [SettingsController::class, 'test'], + 'settings/roles' => [SettingsController::class, 'roles'], + // 节点 'nodes/create' => [NodeController::class, 'create'], 'nodes/store' => [NodeController::class, 'store'], diff --git a/sql/migrations/2026-09-22-storage.sql b/sql/migrations/2026-09-22-storage.sql new file mode 100644 index 0000000..8707b3a --- /dev/null +++ b/sql/migrations/2026-09-22-storage.sql @@ -0,0 +1,24 @@ +-- TripLog 迁移:存储抽象(本地 / 阿里云 OSS) +-- +-- 本文件是「记录 + 可手工执行」的版本;实际由 tools/migrate.php 幂等地跑, +-- 它同样支持 MySQL 8(注意:MySQL 8 不支持 ADD COLUMN IF NOT EXISTS, +-- 所以列的存在性由 PHP 侧查 information_schema 判断)。 + +CREATE TABLE IF NOT EXISTS `app_settings` ( + `setting_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `setting_value` text COLLATE utf8mb4_unicode_ci, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`setting_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点级键值设置'; + +-- attachments 增加存储后端相关列(历史行 storage 默认 local,与旧行为一致) +ALTER TABLE `attachments` + ADD COLUMN `storage` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT 'local|oss', + ADD COLUMN `object_key` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'OSS 对象键;本地存储为 /uploads/... 的相对路径', + ADD COLUMN `mime_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + ADD COLUMN `size_bytes` bigint DEFAULT NULL, + ADD COLUMN `width` int DEFAULT NULL COMMENT '图片宽(非图片为空)', + ADD COLUMN `height` int DEFAULT NULL COMMENT '图片高(非图片为空)', + ADD COLUMN `is_image` tinyint(1) NOT NULL DEFAULT '0'; + +ALTER TABLE `attachments` ADD KEY `idx_storage` (`storage`); diff --git a/src/Attachments.php b/src/Attachments.php new file mode 100644 index 0000000..fb9301b --- /dev/null +++ b/src/Attachments.php @@ -0,0 +1,195 @@ +prepare('INSERT INTO attachments + (target_type, target_id, uploader_id, file_url, file_name, + storage, object_key, mime_type, size_bytes, is_image, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + $stmt->execute([ + $targetType, $targetId, $uploaderId, $fileUrl, $fileName, + $stored['storage'], $stored['key'], $mime, $stored['size'], + $isImage ? 1 : 0, $width, $height, + ]); + return (int)$db->lastInsertId(); + } catch (\PDOException $e) { + // SQLSTATE 42S22 = 列不存在 → 迁移没跑,退回老结构 + if (($e->errorInfo[0] ?? '') !== '42S22') { + throw $e; + } + $stmt = $db->prepare('INSERT INTO attachments + (target_type, target_id, uploader_id, file_url, file_name) + VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$targetType, $targetId, $uploaderId, + $stored['storage'] === Storage::DRIVER_LOCAL ? '/uploads/' . $stored['key'] : $fileName, + $fileName]); + return (int)$db->lastInsertId(); + } + } + + public static function find(int $id): ?array + { + $stmt = Database::get()->prepare('SELECT * FROM attachments WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + /** @return array */ + public static function forTarget(string $targetType, int $targetId): array + { + $stmt = Database::get()->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 = ? AND a.target_id = ? + ORDER BY a.sort_order ASC, a.id ASC'); + $stmt->execute([$targetType, $targetId]); + return $stmt->fetchAll(); + } + + /** 删除一条并清掉它对应的物理对象 */ + public static function delete(int $id): bool + { + $att = self::find($id); + if (!$att) { + return false; + } + + Storage::delete( + self::storageOf($att), + (string)($att['object_key'] ?? ''), + (string)($att['file_url'] ?? '') + ); + + $stmt = Database::get()->prepare('DELETE FROM attachments WHERE id = ?'); + $stmt->execute([$id]); + return true; + } + + /** 清空某个目标下的所有附件(含物理对象) */ + public static function purgeTarget(string $targetType, int $targetId): int + { + $rows = self::forTarget($targetType, $targetId); + foreach ($rows as $row) { + Storage::delete( + self::storageOf($row), + (string)($row['object_key'] ?? ''), + (string)($row['file_url'] ?? '') + ); + } + if ($rows === []) { + return 0; + } + $stmt = Database::get()->prepare('DELETE FROM attachments WHERE target_type = ? AND target_id = ?'); + $stmt->execute([$targetType, $targetId]); + return count($rows); + } + + /** 一批节点的附件数(一次 GROUP BY,避免 N+1) */ + public static function countForNodes(array $nodeIds): array + { + if ($nodeIds === []) return []; + $ids = array_map('intval', $nodeIds); + $ph = implode(',', array_fill(0, count($ids), '?')); + $stmt = Database::get()->prepare("SELECT target_id, COUNT(*) AS cnt FROM attachments + WHERE target_type = '" . self::TARGET_NODE . "' AND target_id IN ($ph) + GROUP BY target_id"); + $stmt->execute($ids); + $out = []; + foreach ($stmt->fetchAll() as $row) { + $out[(int)$row['target_id']] = (int)$row['cnt']; + } + return $out; + } + + /** 历史行没有 storage 列时按本地处理 */ + public static function storageOf(array $att): string + { + return (string)($att['storage'] ?? '') === Storage::DRIVER_OSS + ? Storage::DRIVER_OSS + : Storage::DRIVER_LOCAL; + } + + public static function isImageRow(array $att): bool + { + if (array_key_exists('is_image', $att)) { + return (int)$att['is_image'] === 1; + } + // 老行没这一列 → 看 MIME / 扩展名 + $mime = (string)($att['mime_type'] ?? ''); + if ($mime !== '') { + return Mime::isImageMime($mime); + } + $ext = strtolower(pathinfo((string)($att['file_name'] ?? $att['file_url'] ?? ''), PATHINFO_EXTENSION)); + return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'], true); + } + + /** + * 展示所需的字段,集中在这里算 —— 这样模板里就不必再去碰 file_url + * 这类历史列,也不会在模板层做文件系统访问。 + * + * name :展示用文件名;老行没 file_name 时退回 file_url 的末段 + * size_text :人类可读的体积;老行没 size_bytes 时读本地文件兜底 + * + * @return array{name:string, size_text:string} + */ + public static function describe(array $att): array + { + $name = trim((string)($att['file_name'] ?? '')); + if ($name === '') { + $name = basename((string)($att['file_url'] ?? '')) ?: 'file'; + } + + $size = (int)($att['size_bytes'] ?? 0); + if ($size <= 0) { + // 迁移前落库的老行只有 file_url,量一下本地文件 + $local = self::storageOf($att) === Storage::DRIVER_LOCAL + ? Storage::resolveLocal((string)($att['object_key'] ?? ''), (string)($att['file_url'] ?? '')) + : null; + if ($local !== null && is_file($local)) { + $size = (int)@filesize($local); + } + } + + return ['name' => $name, 'size_text' => \human_size($size)]; + } +} diff --git a/src/Controllers/ActivityController.php b/src/Controllers/ActivityController.php index bc250ba..7a64f1c 100644 --- a/src/Controllers/ActivityController.php +++ b/src/Controllers/ActivityController.php @@ -3,10 +3,12 @@ declare(strict_types=1); namespace App\Controllers; +use App\Attachments; use App\Auth; use App\Comments; use App\Database; use App\Expenses; +use App\Mime; class ActivityController { @@ -143,32 +145,17 @@ class ActivityController $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']; - } - + // 附件表是软关联(target_type + target_id),没有外键级联,必须手工清。 + // 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 —— + // 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。 $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); + + Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id); + foreach ($nodeIds as $nodeId) { + Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId); } - $stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?'); - $stmt->execute([$id]); // 评论同样是软关联 if (!empty($nodeIds)) { @@ -183,12 +170,7 @@ class ActivityController $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); - } + // 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了 if (!empty($activity['cover_image'])) { $this->deleteCoverFile((string)$activity['cover_image']); } @@ -290,14 +272,14 @@ class ActivityController 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)) { + // 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底) + $info = Mime::detectBuffer($raw); + if ($info['mime'] === null || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true)) { http_response_code(400); echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE); exit; } + $ext = Mime::extFor($info['mime']) ?? $ext; $dir = dirname(__DIR__, 2) . '/uploads/covers'; if (!is_dir($dir)) mkdir($dir, 0755, true); @@ -353,18 +335,7 @@ class ActivityController 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); - } - + /** 只删除本站 uploads/covers/ 下的封面文件 */ private function deleteCoverFile(string $url): void { $url = trim($url); diff --git a/src/Controllers/AttachmentController.php b/src/Controllers/AttachmentController.php index 0be2a8d..e5d50f7 100644 --- a/src/Controllers/AttachmentController.php +++ b/src/Controllers/AttachmentController.php @@ -3,118 +3,163 @@ declare(strict_types=1); namespace App\Controllers; +use App\Access; +use App\Attachments; use App\Auth; -use App\Database; +use App\Mime; +use App\Settings; +use App\Storage; class AttachmentController { - private const ALLOWED_MIMES = [ - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - 'image/heic' => 'heic', - 'application/pdf' => 'pdf', - 'application/msword' => 'doc', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', - 'application/vnd.ms-excel' => 'xls', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', - 'application/zip' => 'zip', - 'video/mp4' => 'mp4', - 'video/quicktime' => 'mov', - ]; - - private const MAX_SIZE = 20 * 1024 * 1024; + /** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */ + private const MAX_FILES = 30; 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(); + $node = Access::node($nodeId); // 含 family_id / activity_id / activity_title view('nodes/attachments', [ - 'node' => $node, - 'attachments' => $attachments, - 'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'), + 'node' => $node, + 'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId), + 'maxMb' => (int)Settings::get('photo_max_mb', '12'), + 'driverLabel' => Storage::label(), + 'pageTitle' => '节点照片:' . ($node['title'] ?: '无标题'), ]); } + /** + * 上传附件。 + * + * 一个请求里同时收两类东西: + * files[] —— 普通 multipart 文件(照片原图、PDF、视频……) + * cropped[] —— 前端裁好的图片,JSON 字符串 {"name":..,"data":"data:image/...;base64,.."} + * 分开传是为了省流量:没裁的照片原图直传,裁过的才走 base64。 + */ public function upload(): void { $this->requireLogin(); csrf_check(); $nodeId = (int)($_POST['node_id'] ?? 0); - $this->fetchNodeWithAccess($nodeId); + $node = Access::node($nodeId); - if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) { - flash('error', '未选择文件'); + $maxBytes = 20 * 1024 * 1024; + $photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024; + + $uploaded = 0; + $errors = []; + $count = 0; + + // ── 1) 普通文件 ── + if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) { + foreach ($this->restructureFiles($_FILES['files']) as $file) { + if ($count >= self::MAX_FILES) { + $errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略'; + break; + } + $count++; + + if ($file['error'] !== UPLOAD_ERR_OK) { + $errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')'; + continue; + } + $limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes; + if ($file['size'] > $limit) { + $errors[] = $file['name'] . ':超过大小上限'; + continue; + } + + $info = Mime::detectFile($file['tmp_name'], $file['name']); + $mime = $info['mime']; + if ($mime === null || !isset(Mime::ALLOWED[$mime])) { + $errors[] = $file['name'] . ':不支持的文件类型'; + continue; + } + if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) { + $errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB'; + continue; + } + + $raw = @file_get_contents($file['tmp_name']); + if ($raw === false) { + $errors[] = $file['name'] . ':读取临时文件失败'; + continue; + } + + $err = $this->store($nodeId, $raw, $mime, $file['name'], $info); + if ($err === null) { + $uploaded++; + } else { + $errors[] = $file['name'] . ':' . $err; + } + } + } + + // ── 2) 前端裁剪后的图片 ── + $cropped = $_POST['cropped'] ?? ''; + if (is_string($cropped) && $cropped !== '') { + $items = json_decode($cropped, true); + if (!is_array($items)) { + $errors[] = '裁剪数据解析失败'; + $items = []; + } + foreach ($items as $item) { + if ($count >= self::MAX_FILES) { + $errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略'; + break; + } + $count++; + + $name = (string)($item['name'] ?? 'photo.png'); + $data = (string)($item['data'] ?? ''); + if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) { + $errors[] = $name . ':裁剪结果格式不正确'; + continue; + } + $raw = base64_decode($m[2], true); + if ($raw === false || $raw === '') { + $errors[] = $name . ':图片解码失败'; + continue; + } + if (strlen($raw) > $maxBytes) { + $errors[] = $name . ':超过 20MB'; + continue; + } + + $info = Mime::detectBuffer($raw); + $mime = $info['mime']; + if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) { + $errors[] = $name . ':裁剪结果不是受支持的图片'; + continue; + } + + $err = $this->store($nodeId, $raw, $mime, $this->asImageName($name, $mime), $info); + if ($err === null) { + $uploaded++; + } else { + $errors[] = $name . ':' . $err; + } + } + } + + if ($count === 0 && $uploaded === 0) { + 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} 个文件"; + $msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')'; if (!empty($errors)) { - $msg .= ';失败:' . implode(',', $errors); + $msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5)); } + if (Storage::ossMisconfigured()) { + $msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器'; + } + flash('error', $msg); - redirect('attachments/node', ['id' => $nodeId]); + redirect('attachments/node', ['id' => $nodeId], 'photos'); } public function destroy(): void @@ -122,36 +167,67 @@ class AttachmentController $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) { + $id = (int)($_POST['id'] ?? 0); + $att = Attachments::find($id); + if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) { flash('error', '附件不存在'); redirect('activities/index'); } - $this->fetchNodeWithAccess((int)$att['target_id']); + $node = Access::node((int)$att['target_id']); // 顺带做归属校验 + Attachments::delete($id); - $url = $att['file_url']; - if (strpos($url, '/uploads/attachments/') === 0) { - $file = dirname(__DIR__, 2) . $url; - if (is_file($file)) @unlink($file); + flash('error', '照片已删除'); + redirect('attachments/node', ['id' => (int)$node['id']], 'photos'); + } + + /** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */ + private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string + { + $ext = Mime::extFor($mime); + if ($ext === null) { + return '无法确定扩展名'; } - $stmt = $db->prepare('DELETE FROM attachments WHERE id = ?'); - $stmt->execute([$id]); + try { + $key = Storage::keyFor($ext, 'attachments'); + $stored = Storage::put($key, $raw, $mime); + } catch (\Throwable $e) { + return $e->getMessage(); + } - flash('error', '附件已删除'); - redirect('attachments/node', ['id' => (int)$att['target_id']]); + try { + Attachments::create( + Attachments::TARGET_NODE, + $nodeId, + (int)Auth::id(), + $stored, + $fileName, + $mime, + (bool)$info['is_image'], + $info['width'], + $info['height'] + ); + } catch (\Throwable $e) { + // 入库失败就把刚写的对象删掉,别留孤儿文件 + Storage::delete($stored['storage'], $stored['key']); + return '入库失败:' . $e->getMessage(); + } + + return null; + } + + /** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */ + private function asImageName(string $name, string $mime): string + { + $base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo'; + return $base . '.' . (Mime::extFor($mime) ?? 'jpg'); } private function restructureFiles(array $files): array { $result = []; - $count = count($files['name']); + $count = count($files['name']); for ($i = 0; $i < $count; $i++) { if (empty($files['name'][$i])) continue; $result[] = [ @@ -165,33 +241,10 @@ class AttachmentController 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/MediaController.php b/src/Controllers/MediaController.php new file mode 100644 index 0000000..9e3cf29 --- /dev/null +++ b/src/Controllers/MediaController.php @@ -0,0 +1,147 @@ +[&dl=1] —— 节点附件 */ + public function show(): void + { + $this->requireLogin(); + + $att = Attachments::find((int)($_GET['id'] ?? 0)); + if (!$att) { + $this->abort(404, '附件不存在'); + } + $this->assertTargetInFamily((string)$att['target_type'], (int)$att['target_id']); + + $body = Storage::get( + Attachments::storageOf($att), + (string)($att['object_key'] ?? ''), + (string)($att['file_url'] ?? '') + ); + if ($body === null) { + $this->abort(404, '文件已不存在'); + } + + $mime = (string)($att['mime_type'] ?? ''); + if ($mime === '') { + $mime = Mime::mimeFromName((string)($att['file_name'] ?? '')) ?: 'application/octet-stream'; + } + + $this->stream( + $body, + $mime, + (string)($att['file_name'] ?? ('attachment-' . $att['id'])), + isset($_GET['dl']) && $_GET['dl'] !== '' + ); + } + + /** GET media/cover?id= —— 活动封面 */ + public function cover(): void + { + $this->requireLogin(); + + $id = (int)($_GET['id'] ?? 0); + $stmt = Database::get()->prepare('SELECT family_id, cover_image FROM activities WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + + if (!$row || (int)$row['family_id'] !== (int)Auth::familyId()) { + $this->abort(404, '封面不存在'); + } + + $url = (string)($row['cover_image'] ?? ''); + // 封面目前只存本站 uploads/covers/ 下;沿用 Storage 的路径校验(挡住 ../) + if ($url === '' || strpos($url, '/uploads/covers/') !== 0) { + $this->abort(404, '封面不存在'); + } + $key = ltrim(substr($url, strlen('/uploads/')), '/'); + $body = Storage::get(Storage::DRIVER_LOCAL, $key, $url); + if ($body === null) { + $this->abort(404, '封面文件已不存在'); + } + + $mime = Mime::mimeFromName($url) ?: 'image/jpeg'; + $this->stream($body, $mime, basename($url), false); + } + + /** 附件/封面所在的目标必须属于当前用户的家庭 */ + private function assertTargetInFamily(string $targetType, int $targetId): void + { + $db = Database::get(); + + if ($targetType === Attachments::TARGET_ACTIVITY) { + $stmt = $db->prepare('SELECT family_id FROM activities WHERE id = ?'); + $stmt->execute([$targetId]); + $fam = $stmt->fetchColumn(); + } elseif ($targetType === Attachments::TARGET_NODE) { + $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([$targetId]); + $fam = $stmt->fetchColumn(); + } else { + // comment 等其它目标:按「评论 → 其挂载目标」回溯太绕, + // 一律当作不存在(对外表现与 Access 的 404 一致,不透露资源是否存在) + $this->abort(404, '附件不存在'); + } + + if ($fam === false || (int)$fam !== (int)Auth::familyId()) { + $this->abort(404, '附件不存在'); + } + } + + /** 输出二进制内容;$download=true 时强制下载而不是内联预览 */ + private function stream(string $body, string $mime, string $fileName, bool $download): void + { + // 别让 PHP 的报错/警告混进图片正文(那会让浏览器把图片判成损坏) + @ini_set('display_errors', '0'); + + // 文件名里可能有中文和引号,按 RFC 5987 给一个 ASCII 兜底 + UTF-8 版本 + $ascii = preg_replace('/[^\x20-\x7E]/', '_', $fileName) ?: 'file'; + $ascii = str_replace(['"', '\\'], '_', $ascii); + + header('Content-Type: ' . $mime); + header('Content-Length: ' . strlen($body)); + header('Content-Disposition: ' . ($download ? 'attachment' : 'inline') + . '; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode($fileName)); + header('X-Content-Type-Options: nosniff'); + // 私有内容:允许浏览器缓存,但禁止中间代理缓存 + header('Cache-Control: private, max-age=86400'); + header('Pragma: private'); + + echo $body; + exit; + } + + private function abort(int $code, string $msg): void + { + http_response_code($code); + header('Content-Type: text/plain; charset=utf-8'); + exit($msg); + } + + private function requireLogin(): void + { + // 正常路径上 index.php 已经拦过一道;这里再拦一次只是兜底 + if (!Auth::check()) { + redirect('auth/login'); + } + } +} diff --git a/src/Controllers/NodeController.php b/src/Controllers/NodeController.php index 065ebd9..ccb06ab 100644 --- a/src/Controllers/NodeController.php +++ b/src/Controllers/NodeController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Controllers; use App\Access; +use App\Attachments; use App\Auth; use App\Comments; use App\Database; @@ -59,18 +60,20 @@ class NodeController } $data = $input['data']; + // 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司, + // 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。 $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) + trip_number, 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)'; + :trip_number, :status, :cancel_reason)'; $stmt = $db->prepare($sql); $stmt->execute($data); @@ -106,8 +109,7 @@ class NodeController 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 + trip_number = :trip_number, status = :status, cancel_reason = :cancel_reason WHERE id = :id'; $stmt = $db->prepare($sql); $stmt->execute($data); @@ -174,8 +176,8 @@ class NodeController 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")'); + trip_number, status) + VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")'); $stmt->execute([ (int)$plan['activity_id'], (int)$plan['node_type_id'], @@ -189,11 +191,7 @@ class NodeController $plan['description'], $plan['travel_mode_id'], $plan['vehicle_id'], - $plan['line_name'], - $plan['carrier'], $plan['trip_number'], - $plan['seat_info'], - $plan['gate'], ]); flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况'); @@ -207,14 +205,8 @@ class NodeController $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']]); + // 附件是软关联:交给 Attachments 统一清(同时会删本地文件或 OSS 对象) + Attachments::purgeTarget(Attachments::TARGET_NODE, (int)$node['id']); $stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?'); $stmt->execute([(int)$node['id']]); @@ -223,10 +215,6 @@ class NodeController $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']]); } @@ -249,6 +237,7 @@ class NodeController // 出行方式(连同车辆信息拼成一行展示文本) $travelModeLabel = ''; + $travelModeType = ''; if (!empty($node['travel_mode_id'])) { $stmt = $db->prepare(' SELECT tm.mode_type, v.plate_number, v.model_name @@ -264,18 +253,13 @@ class NodeController } elseif (!empty($tm['model_name'])) { $label .= ' · ' . $tm['model_name']; } - // 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列 - if (!empty($node['line_name'])) { - $label .= ' · ' . $node['line_name']; - } $travelModeLabel = $label; + $travelModeType = (string)$tm['mode_type']; } } - $stmt = $db->prepare('SELECT COUNT(*) FROM attachments - WHERE target_type = "trip_node" AND target_id = ?'); - $stmt->execute([$nodeId]); - $attachmentCount = (int)$stmt->fetchColumn(); + $nodeAttachments = Attachments::forTarget(Attachments::TARGET_NODE, $nodeId); + $attachmentCount = count($nodeAttachments); $familyId = (int)$node['family_id']; @@ -284,8 +268,10 @@ class NodeController 'node_type_name' => (string)$type['name'], 'node_type_icon' => (string)$type['icon'], 'travel_mode_label' => $travelModeLabel, + 'travel_mode_type' => $travelModeType, ]), 'attachmentCount' => $attachmentCount, + 'attachments' => $nodeAttachments, 'members' => Members::forNode($nodeId), 'familyUsers' => Members::familyUsers($familyId), 'roleTags' => Members::roleTagMap($familyId), @@ -414,11 +400,11 @@ class NodeController 'description' => trim($_POST['description'] ?? ''), 'travel_mode_id' => $travelModeId, 'vehicle_id' => $vehicleId, - 'line_name' => $this->strOrNull('line_name'), - 'carrier' => $this->strOrNull('carrier'), + 'line_name' => null, + 'carrier' => null, 'trip_number' => $this->strOrNull('trip_number'), - 'seat_info' => $this->strOrNull('seat_info'), - 'gate' => $this->strOrNull('gate'), + 'seat_info' => null, + 'gate' => null, 'status' => $status, 'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null, ]; @@ -473,15 +459,6 @@ class NodeController 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); diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php new file mode 100644 index 0000000..3a26032 --- /dev/null +++ b/src/Controllers/SettingsController.php @@ -0,0 +1,213 @@ + $s, + 'tableExists' => Settings::tableExists(), + 'schemaNow' => Migrate::currentVersion(Database::get()), + 'schemaTarget' => Migrate::VERSION, + 'driver' => Storage::driver(), + 'misconfigured' => Storage::ossMisconfigured(), + 'ecsRoles' => $ecsRoles, + 'isEcs' => $ecsRoles !== [], + 'pageTitle' => '存储设置', + ]); + } + + public function save(): void + { + Access::requireAdmin(); + csrf_check(); + + $driver = (string)($_POST['storage_driver'] ?? 'local'); + if (!in_array($driver, self::DRIVERS, true)) { + $driver = 'local'; + } + $auth = (string)($_POST['oss_auth'] ?? 'ecs_role'); + if (!in_array($auth, self::AUTHS, true)) { + $auth = 'ecs_role'; + } + + $pairs = [ + 'storage_driver' => $driver, + 'oss_endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')), + 'oss_bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63), + 'oss_prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')), + 'oss_auth' => $auth, + 'oss_role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64), + 'oss_ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128), + 'oss_internal' => empty($_POST['oss_internal']) ? '0' : '1', + 'photo_max_mb' => (string)max(1, min(50, (int)($_POST['photo_max_mb'] ?? 12))), + ]; + + // 密钥留空 = 不修改(避免管理员每次保存都要重新粘贴一遍) + $newSecret = trim((string)($_POST['oss_ak_secret'] ?? '')); + if ($newSecret !== '') { + $pairs['oss_ak_secret'] = $newSecret; + } elseif (!Settings::tableExists() || Settings::get('oss_ak_secret') === '') { + $pairs['oss_ak_secret'] = ''; + } + + if ($driver === 'oss') { + $err = $this->validateOss($pairs, $auth); + if ($err !== null) { + flash('error', $err); + redirect('settings/index'); + } + } + + try { + Settings::put($pairs); + flash('error', '设置已保存。当前生效的存储方式:' . Storage::label(Storage::driver())); + } catch (\Throwable $e) { + flash('error', '保存失败:' . $e->getMessage() + . '(若提示表不存在,请先在服务器上执行 php tools/migrate.php)'); + } + redirect('settings/index'); + } + + /** + * 测试连接。**用表单里当前填的值测**,所以可以先测通再保存。 + * 返回 JSON,供前端按钮就地显示结果。 + */ + public function test(): void + { + Access::requireAdmin(); + header('Content-Type: application/json; charset=utf-8'); + + $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) { + http_response_code(419); + echo json_encode(['ok' => false, 'message' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE); + exit; + } + + $auth = (string)($_POST['oss_auth'] ?? 'ecs_role'); + $secret = trim((string)($_POST['oss_ak_secret'] ?? '')); + if ($secret === '') { + // 前端没填新密钥 → 用已保存的那把 + $secret = (string)Settings::get('oss_ak_secret'); + } + + $cfg = [ + 'endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')), + 'bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63), + 'prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')), + 'auth' => in_array($auth, self::AUTHS, true) ? $auth : 'ecs_role', + 'role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64), + 'ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128), + 'ak_secret' => $secret, + 'internal' => !empty($_POST['oss_internal']), + ]; + + if ($cfg['endpoint'] === '' || $cfg['bucket'] === '') { + echo json_encode(['ok' => false, 'message' => '请先填写 Endpoint 和 Bucket'], JSON_UNESCAPED_UNICODE); + exit; + } + + try { + $result = (new Oss($cfg))->testRoundTrip(); + } catch (\Throwable $e) { + $result = ['ok' => false, 'message' => $e->getMessage(), 'steps' => []]; + } + + $result['endpointResolved'] = (new Oss($cfg))->endpoint(); + echo json_encode($result, JSON_UNESCAPED_UNICODE); + exit; + } + + /** 列出该 ECS 实例可用的 RAM 角色,供前端一键填入 */ + public function roles(): void + { + Access::requireAdmin(); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['roles' => Oss::listEcsRoles()], JSON_UNESCAPED_UNICODE); + exit; + } + + // ────────────────────────────────────────────────── 清洗与校验 + + private function validateOss(array $pairs, string $auth): ?string + { + if ($pairs['oss_endpoint'] === '') { + return '请填写 OSS Endpoint(例如 oss-cn-qingdao.aliyuncs.com)'; + } + if ($pairs['oss_bucket'] === '') { + return '请填写 Bucket 名称'; + } + if (strpos($pairs['oss_endpoint'], 'aliyuncs.com') === false + && strpos($pairs['oss_endpoint'], '.') === false) { + return 'Endpoint 看起来不像域名,请检查'; + } + if ($auth === 'ecs_role' && $pairs['oss_role_name'] === '') { + return '选择 ECS 角色鉴权时,必须填写角色名(可点「检测可用角色」自动获取)'; + } + if ($auth === 'access_key') { + $hasId = $pairs['oss_ak_id'] !== ''; + $hasSecret = ($pairs['oss_ak_secret'] ?? '') !== '' || (string)Settings::get('oss_ak_secret') !== ''; + if (!$hasId || !$hasSecret) { + return '选择 AccessKey 鉴权时,必须填写 AccessKeyId 与 AccessKeySecret'; + } + } + return null; + } + + /** endpoint 统一成纯域名(容忍用户粘贴带 https:// 的完整地址) */ + private function cleanEndpoint(string $v): string + { + $v = strtolower(trim($v)); + $v = preg_replace('#^https?://#', '', $v) ?? $v; + $v = rtrim($v, '/'); + $v = preg_replace('/[^a-z0-9.\-]/', '', $v) ?? $v; + return $v; + } + + private function cleanToken(string $v, int $max): string + { + $v = trim($v); + $v = preg_replace('/[^A-Za-z0-9._\-]/', '', $v) ?? $v; + return mb_substr($v, 0, $max); + } + + private function cleanPrefix(string $v): string + { + $v = trim(str_replace('\\', '/', $v)); + $v = ltrim($v, '/'); + $v = preg_replace('#[^A-Za-z0-9._\-/]#', '', $v) ?? $v; + $v = str_replace('..', '', $v) ?? $v; + if ($v !== '' && substr($v, -1) !== '/') { + $v .= '/'; + } + return mb_substr($v, 0, 200); + } +} diff --git a/src/Controllers/UserController.php b/src/Controllers/UserController.php index 0e73802..a058a8c 100644 --- a/src/Controllers/UserController.php +++ b/src/Controllers/UserController.php @@ -5,6 +5,7 @@ namespace App\Controllers; use App\Auth; use App\Database; +use App\Mime; class UserController { @@ -353,7 +354,6 @@ class UserController $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); @@ -366,9 +366,9 @@ class UserController exit; } - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $mime = finfo_buffer($finfo, $raw); - finfo_close($finfo); + // 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底) + $info = Mime::detectBuffer($raw); + $mime = $info['mime'] ?? ''; if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { http_response_code(400); echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE); @@ -378,7 +378,8 @@ class UserController $dir = dirname(__DIR__, 2) . '/uploads/avatars'; if (!is_dir($dir)) mkdir($dir, 0755, true); - $filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext; + // 扩展名以「实际识别出的类型」为准,别信前端给的 + $filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . (Mime::extFor($mime) ?? $ext); $savePath = $dir . '/' . $filename; if (file_put_contents($savePath, $raw) === false) { diff --git a/src/Migrate.php b/src/Migrate.php new file mode 100644 index 0000000..c762987 --- /dev/null +++ b/src/Migrate.php @@ -0,0 +1,119 @@ +exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `app_settings` ( + `setting_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `setting_value` text COLLATE utf8mb4_unicode_ci, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`setting_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点级键值设置' +SQL); + }); + } + + // 2) attachments 存储相关列 + // 注意:MySQL 8 不支持「加列时带 IF NOT EXISTS」的写法,必须自己查 information_schema + $cols = [ + 'storage' => "varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT 'local|oss'", + 'object_key' => "varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'OSS 对象键,或本地相对路径'", + 'mime_type' => "varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL", + 'size_bytes' => 'bigint DEFAULT NULL', + 'width' => 'int DEFAULT NULL COMMENT \'图片宽(非图片为空)\'', + 'height' => 'int DEFAULT NULL COMMENT \'图片高(非图片为空)\'', + 'is_image' => "tinyint(1) NOT NULL DEFAULT '0'", + ]; + foreach ($cols as $name => $def) { + if (!self::hasColumn($db, 'attachments', $name)) { + $step("attachments 增加列 $name", static function () use ($db, $name, $def): void { + $db->exec("ALTER TABLE `attachments` ADD COLUMN `$name` $def"); + }); + } + } + + // 3) 索引 + if (!self::hasIndex($db, 'attachments', 'idx_storage')) { + $step('attachments 增加索引 idx_storage', static function () use ($db): void { + $db->exec('ALTER TABLE `attachments` ADD KEY `idx_storage` (`storage`)'); + }); + } + + // 4) 记录版本 + $db->prepare('INSERT INTO app_settings (setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)') + ->execute(['schema_version', (string)self::VERSION]); + + return $done; + } + + /** 当前库里的版本号(未迁移过则为 0) */ + public static function currentVersion(PDO $db): int + { + try { + $stmt = $db->prepare('SELECT setting_value FROM app_settings WHERE setting_key = ?'); + $stmt->execute(['schema_version']); + return (int)$stmt->fetchColumn(); + } catch (\Throwable $e) { + return 0; + } + } + + private static function hasTable(PDO $db, string $table): bool + { + $stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'); + $stmt->execute([$table]); + return (int)$stmt->fetchColumn() > 0; + } + + private static function hasColumn(PDO $db, string $table, string $column): bool + { + $stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; + } + + private static function hasIndex(PDO $db, string $table, string $index): bool + { + $stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'); + $stmt->execute([$table, $index]); + return (int)$stmt->fetchColumn() > 0; + } +} diff --git a/src/Mime.php b/src/Mime.php new file mode 100644 index 0000000..c896255 --- /dev/null +++ b/src/Mime.php @@ -0,0 +1,151 @@ + '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', + ]; + + /** 扩展名 → MIME(第 3 层兜底) */ + private const EXT_TO_MIME = [ + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', + 'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic', + 'pdf' => 'application/pdf', 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'zip' => 'application/zip', 'mp4' => 'video/mp4', 'mov' => 'video/quicktime', + ]; + + public static function isImageMime(string $mime): bool + { + return strpos($mime, 'image/') === 0; + } + + public static function extFor(string $mime): ?string + { + if (isset(self::ALLOWED[$mime])) { + return self::ALLOWED[$mime]; + } + return self::isImageMime($mime) ? str_replace('image/', '', $mime) : null; + } + + /** + * 识别磁盘上的文件。 + * @return array{mime:?string,width:?int,height:?int,is_image:bool} + */ + public static function detectFile(string $path, string $originalName = ''): array + { + $size = @getimagesize($path); + if (is_array($size) && !empty($size['mime'])) { + $mime = (string)$size['mime']; + // getimagesize 认不出 webp 之外的现代格式时不会到这里 + return [ + 'mime' => $mime, + 'width' => (int)($size[0] ?? 0) ?: null, + 'height' => (int)($size[1] ?? 0) ?: null, + 'is_image' => true, + ]; + } + + $head = self::readHead($path, 32); + $mime = self::sniffMagic($head) ?? self::mimeFromName($originalName); + + return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)]; + } + + /** + * 识别内存里的字节(前端裁剪后是 base64,没有原始文件名)。 + * @return array{mime:?string,width:?int,height:?int,is_image:bool} + */ + public static function detectBuffer(string $raw): array + { + if ($raw === '') { + return ['mime' => null, 'width' => null, 'height' => null, 'is_image' => false]; + } + + // getimagesizefromstring 同样属于标准库 + $size = @getimagesizefromstring($raw); + if (is_array($size) && !empty($size['mime'])) { + return [ + 'mime' => (string)$size['mime'], + 'width' => (int)($size[0] ?? 0) ?: null, + 'height' => (int)($size[1] ?? 0) ?: null, + 'is_image' => true, + ]; + } + + $mime = self::sniffMagic(substr($raw, 0, 32)); + return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)]; + } + + /** 从扩展名推 MIME(第 3 层) */ + public static function mimeFromName(string $name): ?string + { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + return self::EXT_TO_MIME[$ext] ?? null; + } + + /** 魔数嗅探(第 2 层) */ + private static function sniffMagic(string $head): ?string + { + if ($head === '') return null; + + $sig = static function (string $bytes, int $offset = 0) use ($head): bool { + return substr($head, $offset, strlen($bytes)) === $bytes; + }; + + if ($sig("\xFF\xD8\xFF")) return 'image/jpeg'; + if ($sig("\x89PNG\r\n\x1a\n")) return 'image/png'; + if ($sig('GIF87a') || $sig('GIF89a')) return 'image/gif'; + if ($sig('RIFF') && substr($head, 8, 4) === 'WEBP') return 'image/webp'; + if ($sig('%PDF-')) return 'application/pdf'; + if ($sig('PK\x03\x04')) return 'application/zip'; // docx/xlsx 也是 zip 容器 + if ($sig('PK')) return 'application/zip'; + if ($sig("\xD0\xCF\x11\xE0")) return 'application/vnd.ms-excel'; // 老式 xls/doc + // HEIC / MP4 / MOV:ISO-BMFF,第 5-8 字节是品牌 + if (strlen($head) >= 12 && substr($head, 4, 4) === 'ftyp') { + $brand = substr($head, 8, 4); + if (in_array($brand, ['heic', 'heix', 'hevc', 'heim', 'heis'], true)) return 'image/heic'; + if (in_array($brand, ['qt '], true)) return 'video/quicktime'; + return 'video/mp4'; + } + return null; + } + + private static function readHead(string $path, int $n): string + { + $fh = @fopen($path, 'rb'); + if ($fh === false) return ''; + $head = (string)fread($fh, $n); + fclose($fh); + return $head; + } +} diff --git a/src/Oss.php b/src/Oss.php new file mode 100644 index 0000000..fa988a8 --- /dev/null +++ b/src/Oss.php @@ -0,0 +1,396 @@ +: + * + * 鉴权两种: + * 1) ecs_role —— 从 ECS 实例元数据取 RAM 角色的 STS 临时凭证(含 SecurityToken, + * 需要作为 x-oss-security-token 头,并计入 CanonicalizedOSSHeaders); + * 2) access_key —— 直接用长期 AK/SK。 + * + * 桶是**私有**的:本类从不生成对外直链,取文件一律经 PHP 代理(见 MediaController)。 + */ +final class Oss +{ + private const META_BASE = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/'; + + private string $endpoint; // 已归一化(去掉 scheme、去尾斜杠) + private string $bucket; + private string $prefix; + private string $auth; // ecs_role | access_key + private string $roleName; + private string $akId; + private string $akSecret; + + /** @var array{id:string,secret:string,token:string,expire:int} 运行时凭证 */ + private array $cred = ['id' => '', 'secret' => '', 'token' => '', 'expire' => 0]; + + public function __construct(array $cfg) + { + $this->bucket = trim((string)($cfg['bucket'] ?? '')); + $this->prefix = self::normalizePrefix((string)($cfg['prefix'] ?? '')); + $this->auth = (string)($cfg['auth'] ?? 'ecs_role'); + $this->roleName = (string)($cfg['role_name'] ?? ''); + $this->akId = (string)($cfg['ak_id'] ?? ''); + $this->akSecret = (string)($cfg['ak_secret'] ?? ''); + + $endpoint = trim((string)($cfg['endpoint'] ?? '')); + $endpoint = preg_replace('#^https?://#i', '', $endpoint) ?? $endpoint; + $endpoint = rtrim($endpoint, '/'); + // 「内网」不是单独的参数,而是在 endpoint 里插 -internal: + // oss-cn-qingdao.aliyuncs.com -> oss-cn-qingdao-internal.aliyuncs.com + if (!empty($cfg['internal']) && strpos($endpoint, '-internal.') === false) { + $endpoint = preg_replace('/^([a-z0-9-]+?)(\.[a-z0-9-]+\.[a-z]+)$/i', '$1-internal$2', $endpoint, 1) ?? $endpoint; + } + $this->endpoint = $endpoint; + } + + public function bucket(): string { return $this->bucket; } + public function prefix(): string { return $this->prefix; } + public function endpoint(): string { return $this->endpoint; } + + /** 元数据/设置里的相对键 → 带前缀的完整对象键 */ + public function objectKey(string $key): string + { + return $this->prefix . ltrim($key, '/'); + } + + // ────────────────────────────────────────────────────────────── 基本操作 + + /** 上传(覆盖)。$key 为不含前缀的相对键 */ + public function put(string $key, string $body, string $mime = 'application/octet-stream'): void + { + $res = $this->request('PUT', $this->objectKey($key), [ + 'Content-Type' => $mime, + 'Content-MD5' => base64_encode(md5($body, true)), + ], $body); + if ($res['status'] < 200 || $res['status'] >= 300) { + throw new \RuntimeException('OSS 上传失败(HTTP ' . $res['status'] . '):' . self::short($res['body'])); + } + } + + /** 下载对象内容 */ + public function get(string $key): string + { + $res = $this->request('GET', $this->objectKey($key)); + if ($res['status'] === 404) { + throw new \RuntimeException('对象不存在:' . $key); + } + if ($res['status'] < 200 || $res['status'] >= 300) { + throw new \RuntimeException('OSS 读取失败(HTTP ' . $res['status'] . '):' . self::short($res['body'])); + } + return $res['body']; + } + + /** 读取对象响应头(用于代理时取 Content-Type / Content-Length 而不下载正文) */ + public function head(string $key): ?array + { + $res = $this->request('HEAD', $this->objectKey($key)); + if ($res['status'] < 200 || $res['status'] >= 300) { + return null; + } + return self::parseHeaders($res['headers']); + } + + public function delete(string $key): bool + { + $res = $this->request('DELETE', $this->objectKey($key)); + // 404 视为已不存在,同样算成功(幂等) + return ($res['status'] >= 200 && $res['status'] < 300) || $res['status'] === 404; + } + + /** + * 走一遍「上传 → 读取 → 删除」,验证凭证对目标桶真的有读写权限。 + * 比只做 HEAD 桶更贴近真实用途(很多 RAM 策略不给 HeadBucket)。 + * + * @return array{ok:bool,message:string,steps:string[]} + */ + public function testRoundTrip(): array + { + $key = '.healthcheck/' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.txt'; + $body = 'triplog healthcheck ' . date('c'); + $steps = []; + + try { + $this->put($key, $body, 'text/plain'); + $steps[] = '上传 OK'; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps]; + } + + try { + $got = $this->get($key); + if ($got !== $body) { + return ['ok' => false, 'message' => '读取内容与上传不一致', 'steps' => array_merge($steps, ['读取 内容不符'])]; + } + $steps[] = '读取 OK'; + } catch (\Throwable $e) { + $steps[] = '读取 失败'; + $this->safeDelete($key); + return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps]; + } + + $ok = $this->safeDelete($key); + $steps[] = $ok ? '删除 OK' : '删除 失败'; + + return [ + 'ok' => $ok, + 'message' => $ok ? '连接正常,读写与删除都可用' : '上传读取正常,但删除失败(检查 RAM 策略是否含 DeleteObject)', + 'steps' => $steps, + ]; + } + + private function safeDelete(string $key): bool + { + try { + return $this->delete($key); + } catch (\Throwable $e) { + return false; + } + } + + // ────────────────────────────────────────────────────────────── ECS 元数据 + + /** + * 列出该 ECS 实例可用的 RAM 角色名。 + * 结果在单次请求内缓存 —— 不在 ECS 上时这里要等一次超时,别重复问。 + * + * @return string[] + */ + public static function listEcsRoles(): array + { + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $res = self::httpGet(self::META_BASE, 2); + if ($res === null) { + return $cache = []; + } + $roles = array_filter(array_map('trim', explode("\n", $res))); + return $cache = array_values(array_unique(array_filter($roles, static function (string $r): bool { + return $r !== '' && strpos($r, '404') === false; + }))); + } + + // ────────────────────────────────────────────────────────────── 内部实现 + + private function request(string $verb, string $resourceKey, array $headers = [], string $body = ''): array + { + $cred = $this->credential(); + $date = gmdate('D, d M Y H:i:s \G\M\T'); + + // x-oss-* 头(含 STS 的 security-token)必须参与签名 + $ossHeaders = []; + if ($cred['token'] !== '') { + $ossHeaders['x-oss-security-token'] = $cred['token']; + } + + $canonicalizedOssHeaders = ''; + ksort($ossHeaders); + foreach ($ossHeaders as $k => $v) { + $canonicalizedOssHeaders .= strtolower($k) . ':' . $v . "\n"; + } + + $canonicalizedResource = '/' . $this->bucket . '/' . $resourceKey; + + $stringToSign = $verb . "\n" + . ($headers['Content-MD5'] ?? '') . "\n" + . ($headers['Content-Type'] ?? '') . "\n" + . $date . "\n" + . $canonicalizedOssHeaders + . $canonicalizedResource; + + $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); + + $reqHeaders = array_merge($headers, $ossHeaders, [ + 'Date' => $date, + 'Authorization' => 'OSS ' . $cred['id'] . ':' . $signature, + ]); + + $url = 'https://' . $this->bucket . '.' . $this->endpoint . '/' . self::encodeKey($resourceKey); + + return self::httpRequest($verb, $url, $reqHeaders, $body, 30); + } + + /** + * 取到可用凭证:ECS 角色走元数据(带文件缓存,避免每个请求都去问元数据服务), + * AK/SK 直接返回;两者统一成 {id, secret, token, expire}。 + */ + private function credential(): array + { + if ($this->cred['id'] !== '' && $this->cred['expire'] > time()) { + return $this->cred; + } + + if ($this->auth === 'access_key') { + if ($this->akId === '' || $this->akSecret === '') { + throw new \RuntimeException('未填写 AccessKeyId / AccessKeySecret'); + } + return $this->cred = ['id' => $this->akId, 'secret' => $this->akSecret, 'token' => '', 'expire' => time() + 3600]; + } + + if ($this->roleName === '') { + throw new \RuntimeException('未选择 ECS 角色名'); + } + + // 先看缓存 + $cached = $this->readCredCache(); + if ($cached !== null) { + return $this->cred = $cached; + } + + $url = self::META_BASE . rawurlencode($this->roleName); + $json = self::httpGet($url, 3); + if ($json === null) { + throw new \RuntimeException('读取 ECS 元数据失败(该机器可能不是 ECS,或元数据服务不可达)'); + } + $data = json_decode($json, true); + if (!is_array($data) || empty($data['AccessKeyId']) || empty($data['AccessKeySecret'])) { + throw new \RuntimeException('ECS 元数据未返回有效凭证:' . self::short($json)); + } + if (($data['Code'] ?? 'Success') !== 'Success') { + throw new \RuntimeException('ECS 角色凭证获取失败:' . (string)($data['Code'] ?? '')); + } + + $expire = isset($data['Expiration']) ? (int)strtotime((string)$data['Expiration']) : (time() + 900); + $cred = [ + 'id' => (string)$data['AccessKeyId'], + 'secret' => (string)$data['AccessKeySecret'], + 'token' => (string)($data['SecurityToken'] ?? ''), + // 提前 5 分钟作废,避免签名时刚好过期 + 'expire' => max(time() + 60, $expire - 300), + ]; + $this->writeCredCache($cred); + return $this->cred = $cred; + } + + /** + * 凭证缓存落在 /tmp(**不能在 web 目录下**:STS 临时凭证是密钥, + * 放 uploads/ 会被直接下载走)。open_basedir 允许 /tmp。 + */ + private function cachePath(): string + { + $id = substr(hash('sha256', $this->endpoint . '|' . $this->bucket . '|' . $this->auth . '|' . $this->roleName), 0, 16); + return rtrim(sys_get_temp_dir(), '/\\') . '/triplog_oss_' . $id . '.json'; + } + + private function readCredCache(): ?array + { + $f = $this->cachePath(); + if (!is_file($f)) return null; + $raw = @file_get_contents($f); + if ($raw === false || $raw === '') return null; + $d = json_decode($raw, true); + if (!is_array($d) || empty($d['id']) || empty($d['secret']) || (int)($d['expire'] ?? 0) <= time()) { + @unlink($f); + return null; + } + return ['id' => (string)$d['id'], 'secret' => (string)$d['secret'], + 'token' => (string)($d['token'] ?? ''), 'expire' => (int)$d['expire']]; + } + + private function writeCredCache(array $cred): void + { + $f = $this->cachePath(); + @file_put_contents($f, json_encode($cred)); + @chmod($f, 0600); + } + + // ────────────────────────────────────────────────────────────── 工具 + + /** 对象键编码:逐段 rawurlencode,保留 '/' */ + private static function encodeKey(string $key): string + { + return implode('/', array_map('rawurlencode', explode('/', $key))); + } + + private static function normalizePrefix(string $prefix): string + { + $prefix = trim($prefix); + if ($prefix === '') return ''; + return rtrim($prefix, '/') . '/'; + } + + private static function short(string $s): string + { + $s = trim(preg_replace('/\s+/', ' ', $s) ?? ''); + return mb_strlen($s) > 200 ? mb_substr($s, 0, 200) . '…' : $s; + } + + /** @return array */ + private static function parseHeaders(array $rawHeaders): array + { + $out = []; + foreach ($rawHeaders as $line) { + $pos = strpos($line, ':'); + if ($pos === false) continue; + $out[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1)); + } + return $out; + } + + private static function httpGet(string $url, int $timeout): ?string + { + $res = self::httpRequest('GET', $url, ['Date' => gmdate('D, d M Y H:i:s \G\M\T')], '', $timeout); + return ($res['status'] >= 200 && $res['status'] < 300) ? $res['body'] : null; + } + + /** + * @return array{status:int, body:string, headers:string[]} + */ + private static function httpRequest(string $verb, string $url, array $headers, string $body, int $timeout): array + { + if (!function_exists('curl_init')) { + throw new \RuntimeException('缺少 curl 扩展,无法访问对象存储'); + } + + $ch = curl_init($url); + $headerLines = []; + foreach ($headers as $k => $v) { + $headerLines[] = $k . ': ' . $v; + } + + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $verb, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => min(5, $timeout), + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + if ($body !== '' || in_array($verb, ['PUT', 'POST'], true)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $hsize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false) { + throw new \RuntimeException('网络请求失败:' . $err); + } + + return [ + 'status' => $status, + 'headers' => explode("\r\n", substr((string)$raw, 0, $hsize)), + 'body' => substr((string)$raw, $hsize), + ]; + } +} diff --git a/src/Settings.php b/src/Settings.php new file mode 100644 index 0000000..d79e446 --- /dev/null +++ b/src/Settings.php @@ -0,0 +1,139 @@ + 'local', + + // —— 阿里云 OSS —— + 'oss_endpoint' => '', // 如 oss-cn-qingdao.aliyuncs.com + 'oss_bucket' => '', + 'oss_prefix' => 'triplog/', // 对象键前缀(相当于目录) + 'oss_auth' => 'ecs_role', // ecs_role = 用 ECS 实例 RAM 角色换 STS;access_key = 用 AK/SK + 'oss_role_name' => '', // ECS 角色名,如 SyncCalendarOssRole + 'oss_ak_id' => '', + 'oss_ak_secret' => '', + 'oss_internal' => '1', // 服务器在同地域时走内网 endpoint,免流量费 + 'oss_cname' => '', // 预留:自定义访问域名(当前不使用,代理模式不暴露 OSS) + + // 上传限制 + 'photo_max_mb' => '12', // 单张照片上限(MB) + ]; + + private static ?array $cache = null; + + /** @return array */ + public static function all(): array + { + if (self::$cache !== null) { + return self::$cache; + } + $out = self::DEFAULTS; + try { + $stmt = Database::get()->query('SELECT setting_key, setting_value FROM app_settings'); + foreach ($stmt->fetchAll() as $row) { + $k = (string)$row['setting_key']; + // 只接受已知键,避免历史遗留垃圾键漏进业务 + if (array_key_exists($k, self::DEFAULTS)) { + $out[$k] = (string)($row['setting_value'] ?? ''); + } + } + } catch (\Throwable $e) { + // app_settings 还不存在(迁移没跑)→ 用默认值,不中断请求 + } + return self::$cache = $out; + } + + public static function get(string $key, ?string $default = null): ?string + { + $all = self::all(); + return $all[$key] ?? $default; + } + + public static function flag(string $key): bool + { + $v = self::get($key); + return $v === '1' || $v === 'true' || $v === 'on'; + } + + /** + * 写入若干项。只接受 DEFAULTS 里声明过的键(白名单),避免拼进来的任意键落库。 + * + * @param array $pairs + */ + public static function put(array $pairs, ?PDO $db = null): void + { + $db = $db ?? Database::get(); + $stmt = $db->prepare('INSERT INTO app_settings (setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'); + $saved = 0; + foreach ($pairs as $k => $v) { + if (!array_key_exists($k, self::DEFAULTS)) { + continue; + } + $stmt->execute([$k, (string)$v]); + $saved++; + } + self::$cache = null; // 让下一次 all() 重新读 + if ($saved === 0) { + throw new \RuntimeException('没有可保存的设置项'); + } + } + + public static function tableExists(): bool + { + try { + Database::get()->query('SELECT 1 FROM app_settings LIMIT 1'); + return true; + } catch (\Throwable $e) { + return false; + } + } + + /** 是否在用 OSS 且配置齐全 */ + public static function ossReady(): bool + { + if (self::get('storage_driver') !== 'oss') { + return false; + } + if (self::get('oss_endpoint') === '' || self::get('oss_bucket') === '') { + return false; + } + if (self::get('oss_auth') === 'access_key') { + return self::get('oss_ak_id') !== '' && self::get('oss_ak_secret') !== ''; + } + return self::get('oss_role_name') !== ''; + } + + /** 造一个 OSS 客户端(未启用或配置不全时返回 null) */ + public static function ossClient(): ?Oss + { + if (!self::ossReady()) { + return null; + } + return new Oss([ + 'endpoint' => (string)self::get('oss_endpoint'), + 'bucket' => (string)self::get('oss_bucket'), + 'prefix' => (string)self::get('oss_prefix'), + 'auth' => (string)self::get('oss_auth'), + 'role_name' => (string)self::get('oss_role_name'), + 'ak_id' => (string)self::get('oss_ak_id'), + 'ak_secret' => (string)self::get('oss_ak_secret'), + 'internal' => self::flag('oss_internal'), + ]); + } +} diff --git a/src/Storage.php b/src/Storage.php new file mode 100644 index 0000000..1f5e4bd --- /dev/null +++ b/src/Storage.php @@ -0,0 +1,175 @@ +put($key, $body, $mime); + return ['storage' => self::DRIVER_OSS, 'key' => $key, 'size' => strlen($body)]; + } + + $path = self::localPath($key); + $dir = dirname($path); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new \RuntimeException('无法创建上传目录:' . $dir); + } + if (@file_put_contents($path, $body) === false) { + throw new \RuntimeException('写入文件失败:' . $key); + } + return ['storage' => self::DRIVER_LOCAL, 'key' => $key, 'size' => strlen($body)]; + } + + /** 读取内容;找不到返回 null */ + public static function get(string $storage, string $key, string $fileUrl = ''): ?string + { + if ($storage === self::DRIVER_OSS) { + try { + $oss = Settings::ossClient(); + return $oss === null ? null : $oss->get($key); + } catch (\Throwable $e) { + return null; + } + } + + $path = self::resolveLocal($key, $fileUrl); + if ($path === null || !is_file($path)) { + return null; + } + $data = @file_get_contents($path); + return $data === false ? null : $data; + } + + public static function exists(string $storage, string $key, string $fileUrl = ''): bool + { + if ($storage === self::DRIVER_OSS) { + $oss = Settings::ossClient(); + if ($oss === null) return false; + try { + return $oss->head($key) !== null; + } catch (\Throwable $e) { + return false; + } + } + $path = self::resolveLocal($key, $fileUrl); + return $path !== null && is_file($path); + } + + /** 删除;返回是否确实删掉了(不存在也算成功,保持幂等) */ + public static function delete(string $storage, string $key, string $fileUrl = ''): bool + { + if ($storage === self::DRIVER_OSS) { + try { + $oss = Settings::ossClient(); + return $oss === null ? false : $oss->delete($key); + } catch (\Throwable $e) { + return false; + } + } + + $path = self::resolveLocal($key, $fileUrl); + if ($path === null) { + return true; + } + return !is_file($path) || @unlink($path); + } + + /** + * 本地文件的绝对路径。只接受 uploads/ 下的相对键,挡住 `../` 之类的路径穿越。 + * $fileUrl 是历史遗留的 `/uploads/attachments/xxx.jpg` 形式,作为兼容兜底。 + */ + public static function localPath(string $key): string + { + $key = ltrim(str_replace('\\', '/', $key), '/'); + if (strpos($key, '..') !== false) { + throw new \RuntimeException('非法的对象键:' . $key); + } + return self::uploadsDir() . '/' . $key; + } + + /** + * 解析本地文件的绝对路径(找不到/键非法返回 null)。 + * public 是给「展示层算体积」这类只读场景用的,仍会挡住路径穿越。 + */ + public static function resolveLocal(string $key, string $fileUrl = ''): ?string + { + $key = trim($key) !== '' ? $key : ''; + if ($key !== '') { + try { + return self::localPath($key); + } catch (\Throwable $e) { + return null; + } + } + + // 老数据只有 file_url(/uploads/...) + $url = trim($fileUrl); + if ($url === '' || strpos($url, '/uploads/') !== 0 || strpos($url, '..') !== false) { + return null; + } + return dirname(__DIR__) . $url; + } + + public static function uploadsDir(): string + { + return dirname(__DIR__) . '/uploads'; + } +} diff --git a/src/helpers.php b/src/helpers.php index 3e08a9c..5ec64e0 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -65,19 +65,82 @@ function csrf_token(): string return $_SESSION['_csrf']; } +/** + * 校验 CSRF。两个通道都收: + * 表单 → 隐藏字段 _csrf + * XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body) + */ function csrf_check(): void { $token = $_POST['_csrf'] ?? ''; - if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) { + if ($token === '') { + $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + } + if (!hash_equals($_SESSION['_csrf'] ?? '', (string)$token)) { http_response_code(419); exit('CSRF token 校验失败'); } } +/** 把 php.ini 里的 "8M" / "512K" 这类值换算成字节 */ +function ini_bytes(string $value): int +{ + $value = trim($value); + if ($value === '' || $value === '-1') { + return 0; // 不限 + } + $unit = strtolower(substr($value, -1)); + $num = (int)$value; + switch ($unit) { + case 'g': return $num * 1024 * 1024 * 1024; + case 'm': return $num * 1024 * 1024; + case 'k': return $num * 1024; + default: return $num; + } +} + 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; +} + +/** + * 附件的查看地址 —— 走 media/show 代理,页面里**永远不出现存储后端的真实地址** + * (OSS 桶是私有的,直链既取不到、也不该暴露给浏览器)。 + */ +function media_att(int $attachmentId, bool $download = false): string +{ + return url('media/show', $download ? ['id' => $attachmentId, 'dl' => 1] : ['id' => $attachmentId]); +} + +/** 活动封面地址(同样走代理) */ +function media_cover(int $activityId): string +{ + return url('media/cover', ['id' => $activityId]); +} + +/** + * 头像地址。头像是本站本地文件,但仍做一次前缀校验, + * 避免历史数据里塞了外部地址把图片挂到第三方去。 + */ +function media_avatar(?string $url): string +{ + $url = trim((string)$url); + if ($url === '' || strpos($url, '/uploads/avatars/') !== 0 || strpos($url, '..') !== false) { + return ''; + } + return $url; +} + +/** 人类可读的文件大小 */ +function human_size(?int $bytes): string +{ + $bytes = (int)$bytes; + if ($bytes <= 0) return ''; + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . ' KB'; + return round($bytes / 1024 / 1024, 2) . ' MB'; } \ No newline at end of file diff --git a/templates/activities/_node_card.php b/templates/activities/_node_card.php index 795bedb..aa1078e 100644 --- a/templates/activities/_node_card.php +++ b/templates/activities/_node_card.php @@ -20,12 +20,10 @@ $isExecuted = $cardCategory === 'plan' && in_array((int)$n['id'], $executedPlanI $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; @@ -79,7 +77,7 @@ if ($cardCategory === 'plan') { 详情 - 📎 附件 () + 📷 照片 () 0): ?> diff --git a/templates/activities/form.php b/templates/activities/form.php index 0a3353e..b828a5a 100644 --- a/templates/activities/form.php +++ b/templates/activities/form.php @@ -1,6 +1,8 @@