交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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 字节)
This commit is contained in:
2026-09-22 23:09:37 +08:00
parent 8e4004db85
commit f9a624ef4b
26 changed files with 3894 additions and 1328 deletions
+3 -5
View File
@@ -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') {
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>" class="node-action-link node-action-strong">详情</a>
<a href="<?= url('attachments/node', ['id' => $n['id']]) ?>" class="node-action-link">
📎 附件 (<?= (int)($n['attachment_count'] ?? 0) ?>)
📷 照片 (<?= (int)($n['attachment_count'] ?? 0) ?>)
</a>
<?php if ((int)($n['member_count'] ?? 0) > 0): ?>
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#members" class="node-action-link">
+4 -2
View File
@@ -1,6 +1,8 @@
<?php
$isEdit = $activity !== null;
$cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
// 预览也走媒体代理:页面里不出现 /uploads/... 真实路径
$coverPreview = ($isEdit && $cover !== '') ? media_cover((int)$activity['id']) : '';
?>
<div class="page-header">
<div>
@@ -29,8 +31,8 @@ $cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
<label>封面图</label>
<div class="cover-upload">
<div class="cover-preview" id="cover-preview">
<img id="cover-preview-img" src="<?= e($cover) ?>" alt=""
style="<?= $cover === '' ? 'display:none;' : '' ?>">
<img id="cover-preview-img" src="<?= e($coverPreview) ?>" alt=""
style="<?= $coverPreview === '' ? 'display:none;' : '' ?>">
<div class="cover-placeholder" id="cover-placeholder"
style="<?= $cover === '' ? '' : 'display:none;' ?>">
<div style="font-size:32px;">🖼️</div>
+1 -1
View File
@@ -12,7 +12,7 @@
<?php foreach ($activities as $a): ?>
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
<?php if (!empty($a['cover_image'])): ?>
<img src="<?= e($a['cover_image']) ?>" alt="" class="card-cover">
<img src="<?= e(media_cover((int)$a['id'])) ?>" alt="" class="card-cover">
<?php else: ?>
<div class="card-cover card-cover-placeholder">📸</div>
<?php endif; ?>
+1 -1
View File
@@ -18,7 +18,7 @@
<?php if (!empty($activity['cover_image'])): ?>
<div class="activity-cover">
<img src="<?= e($activity['cover_image']) ?>" alt="">
<img src="<?= e(media_cover((int)$activity['id'])) ?>" alt="">
</div>
<?php endif; ?>
+94 -22
View File
@@ -1,9 +1,16 @@
<?php $__isLoggedIn = \App\Auth::check(); ?>
<?php
$__isLoggedIn = \App\Auth::check();
$__user = $__isLoggedIn ? \App\Auth::user() : null;
$__isAdmin = $__isLoggedIn && \App\Auth::isAdmin();
$__name = (string)($__user['name'] ?? ($_SESSION['user_name'] ?? ''));
$__avatar = (string)($__user['avatar'] ?? '');
$__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><?= e($pageTitle ?? '家庭出行记录') ?></title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/vendor/cropper/cropper.min.css">
@@ -23,24 +30,73 @@
<body>
<?php if ($__isLoggedIn): ?>
<header class="topbar">
<a href="<?= url('activities/index') ?>" class="brand">🏠 家庭出行记录</a>
<nav>
<a href="<?= url('activities/index') ?>">活动列表</a>
<a href="<?= url('activities/create') ?>" class="btn-primary">+ 新建活动</a>
<?php if (\App\Auth::isAdmin()): ?>
<a href="<?= url('node_types/index') ?>">节点类型</a>
<a href="<?= url('vehicles/index') ?>">车辆</a>
<a href="<?= url('travel_modes/index') ?>">出行方式</a>
<a href="<?= url('role_tags/index') ?>">角色标签</a>
<a href="<?= url('users/index') ?>">用户管理</a>
<?php endif; ?>
<span class="user-info">
<a href="<?= url('users/edit', ['id' => \App\Auth::id()]) ?>" style="color:#555; text-decoration:none;">
<?= e($_SESSION['user_name'] ?? '') ?>
</a>
</span>
<a href="<?= url('auth/logout') ?>" class="logout-link">退出</a>
</nav>
<a href="<?= url('activities/index') ?>" class="brand">
<span class="brand-mark">🧭</span>
<span class="brand-text">家庭出行记录</span>
</a>
<div class="topbar-right">
<a href="<?= url('activities/create') ?>" class="btn-primary btn-sm topbar-new">+ 新建活动</a>
<?php /* 原生 details 做下拉:不依赖 JS,键盘可用,点外部失焦由 JS 兜底 */ ?>
<details class="menu" id="user-menu">
<summary class="menu-trigger" aria-label="打开菜单">
<?php if ($__avatar !== ''): ?>
<img class="avatar-xs" src="<?= e(media_avatar($__avatar)) ?>" alt="">
<?php else: ?>
<span class="avatar-xs avatar-initial"><?= e($__initial) ?></span>
<?php endif; ?>
<span class="menu-name"><?= e($__name) ?></span>
<span class="menu-caret" aria-hidden="true">▾</span>
</summary>
<div class="menu-panel">
<div class="menu-head">
<div class="menu-head-name"><?= e($__name) ?></div>
<div class="menu-head-role"><?= $__isAdmin ? '管理员' : '家庭成员' ?></div>
</div>
<a class="menu-item" href="<?= url('activities/index') ?>">
<span class="menu-icon">📋</span> 活动列表
</a>
<a class="menu-item" href="<?= url('activities/create') ?>">
<span class="menu-icon">➕</span> 新建活动
</a>
<?php if (\App\Auth::id()): ?>
<a class="menu-item" href="<?= url('users/edit', ['id' => \App\Auth::id()]) ?>">
<span class="menu-icon">🙋</span> 我的资料
</a>
<?php endif; ?>
<?php if ($__isAdmin): ?>
<div class="menu-sep">管理</div>
<a class="menu-item" href="<?= url('travel_modes/index') ?>">
<span class="menu-icon">🚄</span> 出行方式
</a>
<a class="menu-item" href="<?= url('vehicles/index') ?>">
<span class="menu-icon">🚗</span> 车辆
</a>
<a class="menu-item" href="<?= url('node_types/index') ?>">
<span class="menu-icon">🏷️</span> 节点类型
</a>
<a class="menu-item" href="<?= url('role_tags/index') ?>">
<span class="menu-icon">🎭</span> 角色标签
</a>
<a class="menu-item" href="<?= url('users/index') ?>">
<span class="menu-icon">👥</span> 用户管理
</a>
<a class="menu-item" href="<?= url('settings/index') ?>">
<span class="menu-icon">🗄️</span> 存储设置
</a>
<?php endif; ?>
<div class="menu-sep"></div>
<a class="menu-item menu-item-danger" href="<?= url('auth/logout') ?>">
<span class="menu-icon">🚪</span> 退出登录
</a>
</div>
</details>
</div>
</header>
<?php endif; ?>
@@ -52,7 +108,23 @@
</main>
<?php if ($__isLoggedIn): ?>
<footer class="footer">家庭出行记录 · V1.0</footer>
<footer class="footer">家庭出行记录</footer>
<script>
// 原生 details 不会因为点击面板外部而收起,这里补一句(不依赖它也能用)
document.addEventListener('click', function (ev) {
var menu = document.getElementById('user-menu');
if (menu && menu.open && !menu.contains(ev.target)) {
menu.open = false;
}
});
// Esc 收起
document.addEventListener('keydown', function (ev) {
var menu = document.getElementById('user-menu');
if (ev.key === 'Escape' && menu && menu.open) {
menu.open = false;
}
});
</script>
<?php endif; ?>
</body>
</html>
</html>
+529 -87
View File
@@ -1,139 +1,581 @@
<?php
/**
* 节点照片 / 附件
*
* 交互:选或拖入多张 → 暂存区逐张预览(图片可裁剪)→ 一次提交。
* 提交时只把「裁过的图」转成 base64(cropped[]),没裁的走原文件(files[]),省流量。
* 浏览一律走 media/show 代理,页面里**永远不出现存储后端的真实地址**。
*/
use App\Attachments;
use App\Storage;
$maxMb = (int)($maxMb ?? 12);
$driverLabel = $driverLabel ?? Storage::label();
$ossFallback = Storage::ossMisconfigured();
$postMaxBytes = ini_bytes((string)ini_get('post_max_size'));
$fileMaxBytes = ini_bytes((string)ini_get('upload_max_filesize'));
$effBytes = 0;
foreach ([$postMaxBytes, $fileMaxBytes] as $b) {
if ($b > 0 && ($effBytes === 0 || $b < $effBytes)) $effBytes = $b;
}
$iconByMime = [
'application/pdf' => '📄',
'image/gif' => '🖼️',
'video/mp4' => '🎬',
'video/quicktime' => '🎬',
];
?>
<div class="page-header">
<div>
<h1>节点附件</h1>
<h1>照片与附件</h1>
<p class="muted">
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>">
<?= e($node['activity_title']) ?>
</a>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
·
<?= e($node['title'] ?: '无标题节点') ?>
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>"><?= e($node['title'] ?: '无标题节点') ?></a>
·
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
<div class="actions">
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>" class="btn">返回节点</a>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
</div>
</div>
<form method="post" action="<?= url('attachments/upload') ?>" enctype="multipart/form-data" class="form" style="margin-bottom:24px;">
<?php if ($ossFallback): ?>
<div class="alert alert-warn">
已选择「阿里云 OSS」但配置不完整,照片暂存到本站服务器。请到
<a href="<?= url('settings/index') ?>">存储设置</a> 补全 Endpoint / Bucket / 角色名(或 AccessKey)。
</div>
<?php endif; ?>
<form method="post" action="<?= url('attachments/upload') ?>" enctype="multipart/form-data"
class="form" id="upload-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row">
<label>上传附件</label>
<div class="drop-zone" id="drop-zone">
<label>添加照片 / 附件</label>
<div class="drop-zone" id="drop-zone" tabindex="0" role="button" aria-label="选择或拖入文件">
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
<div class="drop-zone-inner">
<div style="font-size:32px;">📎</div>
<p style="margin:6px 0 0;">点击或拖拽文件到这里</p>
<p class="muted" style="font-size:12px; margin:4px 0 0;">
支持 JPG / PNG / GIF / WebP / PDF / DOC / XLS / MP4 等,单个不超过 20MB
<div class="drop-zone-icon">📷</div>
<p class="drop-zone-title">点击选择,或把照片拖到这里</p>
<p class="muted drop-zone-hint">
可一次选多张。支持 JPG / PNG / GIF / WebP / HEIC、PDF、MP4 等;单张上限 <?= $maxMb ?>MB。
</p>
</div>
</div>
<div id="file-list" class="file-list"></div>
<p class="form-hint">
登机牌、车票、门票随手拍下来即可 —— 航班号 / 车次在节点里填,照片用来留档。
<?php if ($effBytes > 0): ?>
本次请求总上限约 <?= e(human_size($effBytes)) ?>,超了会被服务器截断,可分几批上传。
<?php endif; ?>
</p>
</div>
<div id="stage-wrap" style="display:none;">
<div class="stage-head">
<span class="stage-count" id="stage-count"></span>
<div class="stage-tools">
<button type="button" class="btn btn-sm" id="btn-clear">清空</button>
</div>
</div>
<div class="photo-stage" id="photo-stage"></div>
</div>
<div class="upload-progress" id="progress-wrap" style="display:none;">
<div class="upload-track"><div class="upload-bar" id="progress-bar"></div></div>
<div class="upload-text" id="progress-text">正在上传…</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" id="btn-submit-upload" style="display:none;">开始上传</button>
<button type="submit" class="btn-primary" id="btn-submit" disabled>上传到 <?= e($driverLabel) ?></button>
</div>
</form>
<h2 style="font-size:16px; margin: 32px 0 12px;">已上传(<?= count($attachments) ?>)</h2>
<h2 class="section-title" id="photos">
已上传(<?= count($attachments) ?>)
</h2>
<?php if (empty($attachments)): ?>
<div class="empty">
<p>暂无附件,上传第一张机票/门票/照片吧</p>
<p>还没有照片 —— 把登机牌、车票、门票拍下来传上去吧</p>
</div>
<?php else: ?>
<div class="attachment-grid">
<div class="photo-grid">
<?php foreach ($attachments as $att):
$url = $att['file_url'];
$name = $att['file_name'] ?: basename($url);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$isImage = in_array($ext, ['jpg','jpeg','png','gif','webp','heic'], true);
$filePath = dirname(__DIR__, 2) . $url;
$size = is_file($filePath) ? filesize($filePath) : 0;
$sizeText = $size > 0 ? ($size < 1024 * 1024
? round($size / 1024, 1) . ' KB'
: round($size / 1024 / 1024, 2) . ' MB') : '';
$attId = (int)$att['id'];
$isImg = Attachments::isImageRow($att);
$stored = Attachments::storageOf($att);
$badge = $stored === Storage::DRIVER_OSS ? 'OSS' : '本地';
$desc = Attachments::describe($att);
$name = $desc['name'];
$mime = (string)($att['mime_type'] ?? '');
$icon = $iconByMime[$mime] ?? '📎';
$url = media_att($attId);
$dl = media_att($attId, true);
$sizeTxt = $desc['size_text'];
$dims = '';
if (!empty($att['width']) && !empty($att['height'])) {
$dims = (int)$att['width'] . '×' . (int)$att['height'];
}
$capParts = array_filter([
$att['uploader_name'] ?: '未知',
$sizeTxt,
$dims,
], static fn($v) => $v !== '' && $v !== null);
?>
<div class="attachment-card">
<a href="<?= e($url) ?>" target="_blank" class="attachment-link">
<?php if ($isImage): ?>
<img src="<?= e($url) ?>" alt="" class="attachment-thumb">
<div class="photo-card">
<a href="<?= e($url) ?>" target="_blank" rel="noopener" title="<?= e($name) ?>">
<?php if ($isImg): ?>
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
<?php else: ?>
<div class="attachment-icon">
<?php
$icons = ['pdf'=>'📄','doc'=>'📝','docx'=>'📝','xls'=>'📊','xlsx'=>'📊','mp4'=>'🎬','mov'=>'🎬','zip'=>'🗜️'];
echo $icons[$ext] ?? '📎';
?>
</div>
<div class="attachment-icon"><?= $icon ?></div>
<?php endif; ?>
</a>
<div class="attachment-meta">
<div class="attachment-name" title="<?= e($name) ?>"><?= e($name) ?></div>
<div class="attachment-sub">
<?= e($att['uploader_name'] ?: '未知') ?>
<?php if ($sizeText): ?> · <?= e($sizeText) ?><?php endif; ?>
</div>
<form method="post" action="<?= url('attachments/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除此附件?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$att['id'] ?>">
<button type="submit" class="attachment-delete" title="删除">×</button>
</form>
<span class="photo-tag photo-tag-<?= $stored === Storage::DRIVER_OSS ? 'oss' : 'local' ?>">
<?= e($badge) ?>
</span>
<div class="photo-cap" title="<?= e($name) ?>">
<span class="photo-cap-name"><?= e($name) ?></span>
<span class="photo-cap-sub"><?= e(implode(' · ', $capParts)) ?></span>
<span class="photo-cap-links">
<a href="<?= e($url) ?>" target="_blank" rel="noopener">查看</a>
<a href="<?= e($dl) ?>">下载</a>
</span>
</div>
<form method="post" action="<?= url('attachments/destroy') ?>" class="photo-del-form"
onsubmit="return confirm('确定删除「<?= e($name) ?>」?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= $attId ?>">
<button type="submit" class="photo-del" title="删除">×</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* ── 裁剪弹窗 ─────────────────────────────────────────────── */ ?>
<div class="modal" id="crop-modal" aria-hidden="true">
<div class="modal-backdrop" data-close="1"></div>
<div class="modal-content modal-content-wide">
<h3 class="modal-title" id="crop-title">裁剪照片</h3>
<p class="modal-hint">拖动选框选好范围,确认后只上传裁剪结果。</p>
<div class="crop-container crop-container-wide">
<img id="crop-image" alt="">
</div>
<div class="modal-actions">
<button type="button" class="btn" id="crop-cancel">取消</button>
<button type="button" class="btn" id="crop-reset">重置</button>
<button type="button" class="btn-primary" id="crop-apply">确认裁剪</button>
</div>
</div>
</div>
<script>
(function () {
var dropZone = document.getElementById('drop-zone');
'use strict';
var form = document.getElementById('upload-form');
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
var btnSubmit = document.getElementById('btn-submit-upload');
if (!dropZone || !fileInput) return;
var stageWrap = document.getElementById('stage-wrap');
var stage = document.getElementById('photo-stage');
var stageCount= document.getElementById('stage-count');
var btnClear = document.getElementById('btn-clear');
var btnSubmit = document.getElementById('btn-submit');
var progWrap = document.getElementById('progress-wrap');
var progBar = document.getElementById('progress-bar');
var progText = document.getElementById('progress-text');
dropZone.addEventListener('click', function () { fileInput.click(); });
var modal = document.getElementById('crop-modal');
var cropImg = document.getElementById('crop-image');
var cropTitle = document.getElementById('crop-title');
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('dragging');
});
dropZone.addEventListener('dragleave', function () {
dropZone.classList.remove('dragging');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('dragging');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
refreshList();
var csrf = <?= json_encode(csrf_token()) ?>;
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
var postLimit = <?= (int)$effBytes ?>;
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
var nodeId = <?= (int)$node['id'] ?>;
/** 服务端也可以拒,但 30 个的上限在这里先挡一道 */
var MAX_FILES = 30;
var items = []; // 暂存项
var seq = 0;
var urls = []; // 需要 revoke 的 objectURL
var cropper = null;
var cropUid = null;
var IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
function extOf(name) {
var i = String(name || '').lastIndexOf('.');
return i < 0 ? '' : String(name).slice(i + 1).toLowerCase();
}
function isImageFile(f) {
if (f.type && f.type.indexOf('image/') === 0) return true;
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
}
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
function canvasSafe(f) {
var t = String(f.type || '');
if (t === 'image/heic' || t === 'image/heif') return false;
if (!t && ['heic', 'heif'].indexOf(extOf(f.name)) >= 0) return false;
return true;
}
function fmtSize(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(2) + ' MB';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function dataUrlBytes(dataUrl) {
var i = String(dataUrl).indexOf(',');
if (i < 0) return 0;
var b64 = String(dataUrl).slice(i + 1);
var pad = b64.endsWith('==') ? 2 : (b64.endsWith('=') ? 1 : 0);
return Math.floor(b64.length * 3 / 4) - pad;
}
function find(uid) {
for (var i = 0; i < items.length; i++) if (items[i].uid === uid) return items[i];
return null;
}
// ── 加入暂存区 ──────────────────────────────────────────────
function addFiles(list) {
var arr = Array.prototype.slice.call(list || []);
if (!arr.length) return;
var rejected = [];
var room = MAX_FILES - items.length;
arr.forEach(function (f) {
if (room <= 0) { rejected.push(f.name + ':一次最多 ' + MAX_FILES + ' 个'); return; }
if (!isImageFile(f) && f.type === 'image/heic') { /* 仍允许直传 */ }
if (isImageFile(f) && maxBytes > 0 && f.size > maxBytes) {
rejected.push(f.name + ':超过 ' + (maxBytes / 1024 / 1024) + 'MB');
return;
}
var url = isImageFile(f) ? URL.createObjectURL(f) : '';
if (url) urls.push(url);
items.push({
uid: ++seq,
file: f,
name: f.name || ('photo.' + (extOf(f.name) || 'jpg')),
size: f.size,
type: f.type || '',
image: isImageFile(f),
croppable: isImageFile(f) && canvasSafe(f),
cropped: null, // 裁剪后的 dataURL
thumb: url // objectURL 或 dataURL
});
room--;
});
if (rejected.length) alert(rejected.join('\n'));
render();
}
function removeItem(uid) {
for (var i = 0; i < items.length; i++) {
if (items[i].uid === uid) {
if (items[i].thumb && items[i].thumb.indexOf('blob:') === 0) {
try { URL.revokeObjectURL(items[i].thumb); } catch (e) {}
}
items.splice(i, 1);
break;
}
}
});
render();
}
fileInput.addEventListener('change', refreshList);
function refreshList() {
fileList.innerHTML = '';
var files = fileInput.files;
if (!files.length) {
btnSubmit.style.display = 'none';
function render() {
stage.innerHTML = '';
if (!items.length) {
stageWrap.style.display = 'none';
btnSubmit.disabled = true;
updateSubmitLabel();
return;
}
for (var i = 0; i < files.length; i++) {
var f = files[i];
var size = f.size < 1024 * 1024
? (f.size / 1024).toFixed(1) + ' KB'
: (f.size / 1024 / 1024).toFixed(2) + ' MB';
var row = document.createElement('div');
row.className = 'file-row';
row.textContent = '📎 ' + f.name + ' (' + size + ')';
fileList.appendChild(row);
}
btnSubmit.style.display = 'inline-block';
stageWrap.style.display = 'block';
var total = 0;
items.forEach(function (it) {
total += it.cropped ? dataUrlBytes(it.cropped) : it.size;
});
stageCount.textContent = items.length + ' 个待上传 · 约 ' + fmtSize(total);
btnSubmit.disabled = false;
updateSubmitLabel();
items.forEach(function (it) {
var el = document.createElement('div');
el.className = 'photo-item';
var inner = '';
if (it.image && it.thumb) {
inner += '<img class="photo-thumb" src="' + esc(it.thumb) + '" alt="">';
} else {
inner += '<div class="photo-thumb photo-thumb-icon">📎</div>';
}
inner += '<div class="photo-info">'
+ '<span class="photo-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
+ fmtSize(it.cropped ? dataUrlBytes(it.cropped) : it.size)
+ '</div>';
if (it.cropped) {
inner += '<span class="photo-badge">已裁剪</span>';
} else if (it.image && !it.croppable) {
inner += '<span class="photo-badge photo-badge-plain">原图上传</span>';
}
inner += '<div class="photo-actions">'
+ '<button type="button" data-act="crop" data-uid="' + it.uid + '"'
+ (it.croppable ? '' : ' disabled title="该格式无法在浏览器中裁剪"') + '>裁剪</button>'
+ '<button type="button" data-act="del" data-uid="' + it.uid + '">移除</button>'
+ '</div>';
el.innerHTML = inner;
stage.appendChild(el);
});
}
function updateSubmitLabel() {
if (btnSubmit.disabled) return;
btnSubmit.textContent = '上传 ' + items.length + ' 个到 <?= e($driverLabel) ?>';
}
stage.addEventListener('click', function (ev) {
var btn = ev.target.closest ? ev.target.closest('button[data-act]') : null;
if (!btn) return;
var uid = parseInt(btn.getAttribute('data-uid'), 10);
if (btn.getAttribute('data-act') === 'del') removeItem(uid);
else if (btn.getAttribute('data-act') === 'crop') openCrop(uid);
});
btnClear.addEventListener('click', function () {
if (!items.length) return;
if (!confirm('清空暂存区里的 ' + items.length + ' 个文件?')) return;
items.slice().forEach(function (it) { removeItem(it.uid); });
});
// ── 选择 / 拖放 ────────────────────────────────────────────
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener('change', function () {
addFiles(fileInput.files);
fileInput.value = ''; // 允许连续选同一个文件
});
['dragenter', 'dragover'].forEach(function (t) {
dropZone.addEventListener(t, function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.add('dragging');
});
});
['dragleave', 'dragend'].forEach(function (t) {
dropZone.addEventListener(t, function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.remove('dragging');
});
});
dropZone.addEventListener('drop', function (ev) {
ev.preventDefault(); ev.stopPropagation();
dropZone.classList.remove('dragging');
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) {
addFiles(ev.dataTransfer.files);
}
});
// 别让文件掉在页面其它地方被浏览器直接打开
window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
window.addEventListener('drop', function (ev) { ev.preventDefault(); });
// ── 裁剪 ──────────────────────────────────────────────────
function openCrop(uid) {
var it = find(uid);
if (!it || !it.croppable) return;
if (typeof window.Cropper === 'undefined') {
alert('裁剪组件没加载出来,将按原图上传。');
return;
}
cropUid = uid;
cropTitle.textContent = '裁剪:' + it.name;
// 每次都换一个新的 img 节点,避免 Cropper 复用旧实例时状态残留
var fresh = document.createElement('img');
fresh.id = 'crop-image';
fresh.alt = '';
cropImg.parentNode.replaceChild(fresh, cropImg);
cropImg = fresh;
cropImg.onerror = function () {
alert('浏览器解不开这张图(可能是 HEIC 等格式),将按原图上传。');
closeCrop();
var cur = find(cropUid);
if (cur) cur.croppable = false;
render();
};
cropImg.onload = function () {
if (typeof window.Cropper === 'undefined') return;
cropper = new window.Cropper(cropImg, {
viewMode: 1,
dragMode: 'move',
autoCropArea: 1,
responsive: true,
background: false,
aspectRatio: NaN // 自由比例
});
};
cropImg.src = it.cropped ? it.cropped : (it.thumb || URL.createObjectURL(it.file));
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeCrop() {
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (cropper) { cropper.destroy(); cropper = null; }
cropUid = null;
}
modal.addEventListener('click', function (ev) {
if (ev.target.getAttribute && ev.target.getAttribute('data-close') === '1') closeCrop();
});
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && modal.classList.contains('open')) closeCrop();
});
document.getElementById('crop-cancel').addEventListener('click', closeCrop);
document.getElementById('crop-reset').addEventListener('click', function () {
if (cropper) cropper.reset();
});
document.getElementById('crop-apply').addEventListener('click', function () {
if (!cropper || cropUid === null) { closeCrop(); return; }
var it = find(cropUid);
if (!it) { closeCrop(); return; }
// 限制长边,避免 base64 把请求撑爆(登机牌 1800px 已经足够看清)
var canvas = cropper.getCroppedCanvas({
maxWidth: 1800,
maxHeight: 1800,
imageSmoothingQuality: 'high',
fillColor: '#ffffff'
});
if (!canvas) { alert('裁剪失败,请重试。'); return; }
var mime = /^image\/(png|webp)$/.test(it.type) ? it.type : 'image/jpeg';
var dataUrl;
try {
dataUrl = canvas.toDataURL(mime, 0.9);
} catch (e) {
alert('导出裁剪结果失败:' + e.message);
return;
}
it.cropped = dataUrl;
it.thumb = dataUrl;
it.name = it.name.replace(/\.[^.]+$/, '') + (mime === 'image/png' ? '.png'
: mime === 'image/webp' ? '.webp' : '.jpg');
closeCrop();
render();
});
// ── 提交 ──────────────────────────────────────────────────
form.addEventListener('submit', function (ev) {
ev.preventDefault();
if (!items.length || btnSubmit.disabled) return;
var fd = new FormData();
fd.append('_csrf', csrf);
fd.append('node_id', String(nodeId));
var croppedArr = [];
var total = 0;
items.forEach(function (it) {
if (it.cropped) {
croppedArr.push({ name: it.name, data: it.cropped });
total += dataUrlBytes(it.cropped);
} else {
fd.append('files[]', it.file, it.name);
total += it.size;
}
});
if (croppedArr.length) fd.append('cropped', JSON.stringify(croppedArr));
if (postLimit > 0 && total > postLimit) {
alert('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 ' + fmtSize(postLimit)
+ '。请分几批上传。');
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
progWrap.style.display = 'block';
btnSubmit.disabled = true;
progBar.style.width = '0%';
progText.textContent = '正在上传 ' + items.length + ' 个文件…';
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) return;
var pct = Math.round(e.loaded / e.total * 100);
progBar.style.width = pct + '%';
progText.textContent = '正在上传… ' + pct + '%(' + fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ')';
};
xhr.onload = function () {
// 控制器最后是 redirect,XHR 会自动跟到目标页,所以 2xx/3xx 都算成功
if (xhr.status >= 200 && xhr.status < 400) {
progBar.style.width = '100%';
progText.textContent = '上传完成,正在刷新…';
window.location.href = location.pathname + location.search + '#photos';
return;
}
fail('上传失败(HTTP ' + xhr.status + '):' + snippet(xhr.responseText));
};
xhr.onerror = function () { fail('网络错误,上传没成功'); };
xhr.onabort = function () { fail('上传被中断'); };
function snippet(html) {
var t = String(html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
return t.slice(0, 160);
}
function fail(msg) {
progWrap.style.display = 'none';
btnSubmit.disabled = false;
alert(msg);
}
xhr.send(fd);
});
render();
})();
</script>
</script>
+125 -108
View File
@@ -20,6 +20,20 @@ $dtLocal = static function ($value): string {
};
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? null);
/**
* 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。
* 座位号/登机口这类「一趟车多人同行」时根本没法记,所以不再收集。
*/
$tripField = [
'plane' => ['label' => '航班号', 'ph' => '例如:CA1234'],
'train' => ['label' => '车次', 'ph' => '例如:G1234'],
'bus' => ['label' => '班次', 'ph' => '例如:T108、8 路'],
'subway' => ['label' => '线路', 'ph' => '例如:地铁 10 号线'],
'ship' => ['label' => '航次', 'ph' => '例如:渤海轮渡 3 号'],
'taxi' => ['label' => '叫车备注', 'ph' => '例如:尾号 1234'],
'other' => ['label' => '车次 / 班次', 'ph' => '例如:G1234、CA1234'],
];
?>
<div class="page-header">
<div>
@@ -54,6 +68,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
</option>
<?php endforeach; ?>
</select>
<div class="muted">关联后可以在计划卡片上看到「已成行」。</div>
</div>
<?php endif; ?>
@@ -87,7 +102,40 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
</div>
<fieldset class="fieldset">
<legend>起点信息(可选)</legend>
<legend>出行方式</legend>
<div class="form-row">
<label for="travel-mode-select">选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
</select>
<div class="muted">
没有合适的方式?在右上角菜单的「出行方式」里添加。
</div>
</div>
<?php /* 只有一个交通字段:飞机=航班号、火车=车次,标签随方式切换 */ ?>
<div class="form-row" id="field-trip-number" style="display:none;">
<label id="trip-number-label" for="trip-number-input">车次 / 航班号</label>
<input type="text" name="trip_number" id="trip-number-input" maxlength="50"
placeholder="例如:G1234、CA1234"
value="<?= e($n['trip_number'] ?? '') ?>">
<div class="muted" id="trip-number-hint"></div>
</div>
<div class="form-row" id="field-no-trip" style="display:none;">
<div class="muted" id="no-trip-hint"></div>
</div>
</fieldset>
<fieldset class="fieldset">
<legend>起点(可选)</legend>
<div class="form-row">
<label>起点名称(输入地址自动补全)</label>
<input type="text" name="start_location_name" id="start-location-input"
@@ -101,7 +149,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
</fieldset>
<fieldset class="fieldset">
<legend>终点信息(可选)</legend>
<legend>终点(可选)</legend>
<div class="form-row">
<label>终点名称(输入地址自动补全)</label>
<input type="text" name="end_location_name" id="end-location-input"
@@ -114,52 +162,6 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
</div>
</fieldset>
<fieldset class="fieldset">
<legend>出行方式(可选)</legend>
<div class="form-row">
<label>选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-line_name">
<label>线路名称</label>
<input type="text" name="line_name" maxlength="100" placeholder="例如:京沪高铁、地铁10号线"
value="<?= e($n['line_name'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus taxi bike ship other" id="field-carrier">
<label>承运公司</label>
<input type="text" name="carrier" maxlength="100" placeholder="例如:中国铁路、中国国航、滴滴"
value="<?= e($n['carrier'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-trip_number">
<label>车次 / 航班号 / 线路号</label>
<input type="text" name="trip_number" maxlength="50" placeholder="例如:G1234、CA1234"
value="<?= e($n['trip_number'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship other" id="field-seat_info">
<label>座位号 / 车厢</label>
<input type="text" name="seat_info" maxlength="100" placeholder="例如:5车12A、商务舱3A"
value="<?= e($n['seat_info'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship" id="field-gate">
<label>登机口 / 站台</label>
<input type="text" name="gate" maxlength="20" placeholder="例如:B23、T3航站楼C15"
value="<?= e($n['gate'] ?? '') ?>">
</div>
</fieldset>
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($n['description'] ?? '') ?></textarea>
@@ -169,8 +171,8 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
<fieldset class="fieldset">
<legend>执行状态</legend>
<div class="form-row">
<label>状态</label>
<select name="status">
<label for="status-select">状态</label>
<select name="status" id="status-select">
<?php foreach ($statusOptions as $st): ?>
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
<?= e($statusLabels[$st]) ?>
@@ -194,20 +196,53 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
<script>
(function () {
// ===== 出行方式动态字段 =====
// ===== 交通字段:只一个输入框,标签随出行方式变 =====
var TRIP = <?= json_encode($tripField, JSON_UNESCAPED_UNICODE) ?>;
// 这些方式不需要车次/航班号(自驾、步行、骑行)
var NO_TRIP = ['drive_own', 'drive_rental', 'walk', 'bike'];
var modeSelect = document.getElementById('travel-mode-select');
var tripRow = document.getElementById('field-trip-number');
var tripLabel = document.getElementById('trip-number-label');
var tripInput = document.getElementById('trip-number-input');
var tripHint = document.getElementById('trip-number-hint');
var noTripRow = document.getElementById('field-no-trip');
var noTripHint = document.getElementById('no-trip-hint');
function refreshTransport() {
if (!modeSelect || !tripRow) return;
var opt = modeSelect.options[modeSelect.selectedIndex];
var mode = opt ? (opt.getAttribute('data-mode-type') || '') : '';
if (mode === '') {
tripRow.style.display = 'none';
noTripRow.style.display = 'none';
return;
}
if (NO_TRIP.indexOf(mode) !== -1) {
tripRow.style.display = 'none';
noTripRow.style.display = '';
noTripHint.textContent = (mode === 'walk' || mode === 'bike')
? '步行 / 骑行不用填车次。'
: '自驾不用填车次,车牌已在出行方式里。';
return;
}
var cfg = TRIP[mode] || TRIP['other'];
noTripRow.style.display = 'none';
tripRow.style.display = '';
tripLabel.textContent = cfg.label;
tripInput.placeholder = cfg.ph;
tripHint.textContent = '只填这一个就够了' +
(mode === 'plane' ? '——航班号本身就包含了航空公司。'
: mode === 'train' ? '——车次就够,座位号对多人同行没意义。'
: '。');
}
if (modeSelect) {
var details = document.querySelectorAll('.transport-detail');
var refresh = function () {
var opt = modeSelect.options[modeSelect.selectedIndex];
var modeType = opt ? (opt.getAttribute('data-mode-type') || '') : '';
details.forEach(function (el) {
var allow = el.getAttribute('data-fields').split(' ');
el.style.display = allow.indexOf(modeType) !== -1 ? '' : 'none';
});
};
modeSelect.addEventListener('change', refresh);
refresh();
modeSelect.addEventListener('change', refreshTransport);
refreshTransport();
}
// ===== 高德地址自动补全(自定义下拉) =====
@@ -224,10 +259,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
return;
}
if (waited >= maxWait) {
console.error('[AMap] 等待超时。可能原因:');
console.error(' 1. Key 无效或未绑定域名白名单');
console.error(' 2. 安全密钥不匹配');
console.error(' 3. 网络无法访问 webapi.amap.com');
console.error('[AMap] 等待超时。可能原因:Key 无效 / 未绑定域名 / 无法访问 webapi.amap.com');
return;
}
waited += 200;
@@ -255,37 +287,34 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
wrapper.appendChild(dropdown);
var autoComplete = new AMap.AutoComplete({});
var currentIndex = -1;
var currentResults = [];
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function renderDropdown(results) {
currentResults = results || [];
currentIndex = -1;
if (!currentResults.length) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = currentResults.map(function (item, i) {
var name = item.name || '';
var district = item.district || '';
var address = item.address || '';
var sub = [district, address].filter(Boolean).join(' ');
return '<div class="amap-dropdown-item" data-index="' + i + '">' +
'<div class="amap-dropdown-name">' + escapeHtml(name) + '</div>' +
(sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '') +
'</div>';
var sub = [item.district || '', item.address || ''].filter(Boolean).join(' ');
return '<div class="amap-dropdown-item" data-index="' + i + '">'
+ '<div class="amap-dropdown-name">' + escapeHtml(item.name || '') + '</div>'
+ (sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '')
+ '</div>';
}).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
el.addEventListener('mousedown', function (ev) {
ev.preventDefault(); // 防止 input 失焦
var idx = parseInt(el.getAttribute('data-index'), 10);
selectItem(currentResults[idx]);
selectItem(currentResults[parseInt(el.getAttribute('data-index'), 10)]);
});
});
}
@@ -293,9 +322,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
function selectItem(item) {
if (!item) return;
input.value = item.name || '';
if (addrField) {
addrField.value = item.address || item.district || '';
}
if (addrField) addrField.value = item.address || item.district || '';
if (item.location) {
if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng;
@@ -303,12 +330,6 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
dropdown.style.display = 'none';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
});
}
var searchTimer = null;
input.addEventListener('input', function () {
var keyword = input.value.trim();
@@ -331,39 +352,35 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
input.addEventListener('blur', function () {
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
});
input.addEventListener('focus', function () {
if (currentResults.length) dropdown.style.display = 'block';
});
input.addEventListener('keydown', function (e) {
input.addEventListener('keydown', function (ev) {
if (dropdown.style.display === 'none') return;
var items = dropdown.querySelectorAll('.amap-dropdown-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
function highlight() {
items.forEach(function (el, i) { el.classList.toggle('active', i === currentIndex); });
}
if (ev.key === 'ArrowDown') {
ev.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
highlight(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlight();
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
highlight(items);
} else if (e.key === 'Enter') {
highlight();
} else if (ev.key === 'Enter') {
if (currentIndex >= 0 && currentResults[currentIndex]) {
e.preventDefault();
ev.preventDefault();
selectItem(currentResults[currentIndex]);
}
} else if (e.key === 'Escape') {
} else if (ev.key === 'Escape') {
dropdown.style.display = 'none';
}
});
function highlight(items) {
items.forEach(function (el, i) {
el.classList.toggle('active', i === currentIndex);
});
}
}
})();
</script>
+61 -4
View File
@@ -3,6 +3,7 @@
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
* 一个节点一页,把挂在这个节点上的所有记录集中起来
*/
use App\Attachments;
use App\Comments;
$statusLabels = [
@@ -17,9 +18,23 @@ if (!isset($statusLabels[$st])) $st = 'planned';
$sLoc = json_decode_field($node['start_location'] ?? null);
$eLoc = json_decode_field($node['end_location'] ?? null);
/**
* 交通只记一个号码(航班号 / 车次 / 班次)——
* 航班号本身就隐含航空公司,座位号对多人同行又没法记,所以都不收。
*/
$tripFieldLabels = [
'plane' => '航班号',
'train' => '车次',
'bus' => '班次',
'subway' => '线路',
'ship' => '航次',
'taxi' => '备注',
];
$tripLabel = $tripFieldLabels[(string)($node['travel_mode_type'] ?? '')] ?? '车次 / 班次';
$transportParts = [];
foreach (['trip_number', 'line_name', 'carrier', 'seat_info', 'gate'] as $field) {
if (!empty($node[$field])) $transportParts[] = $node[$field];
if (!empty($node['trip_number'])) {
$transportParts[] = $tripLabel . ' ' . $node['trip_number'];
}
$durationText = '';
@@ -42,7 +57,7 @@ if (!empty($node['duration_minutes'])) {
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/edit', ['id' => $node['id']]) ?>" class="btn">编辑节点</a>
<a href="<?= url('attachments/node', ['id' => $node['id']]) ?>" class="btn">📎 附件 (<?= (int)$attachmentCount ?>)</a>
<a href="<?= url('attachments/node', ['id' => $node['id']]) ?>" class="btn">📷 照片 (<?= (int)$attachmentCount ?>)</a>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
<form method="post" action="<?= url('nodes/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除这个节点?它的附件、参与记录、花销和讨论都会一并删除。');">
@@ -77,7 +92,7 @@ if (!empty($node['duration_minutes'])) {
<?php endif; ?>
<?php if (!empty($transportParts)): ?>
<div class="detail-item">
<span class="detail-label">交通细节</span>
<span class="detail-label">交通</span>
<span class="detail-value"><?= e(implode(' · ', $transportParts)) ?></span>
</div>
<?php endif; ?>
@@ -117,6 +132,48 @@ if (!empty($node['duration_minutes'])) {
<?php endif; ?>
</div>
<?php
/* 照片墙:地址一律走 media/show 代理,页面里不出现存储后端的真实地址 */
$photoRows = [];
$otherCount = 0;
foreach (($attachments ?? []) as $attRow) {
if (Attachments::isImageRow($attRow)) {
$photoRows[] = $attRow;
} else {
$otherCount++;
}
}
$showPhotos = array_slice($photoRows, 0, 12);
?>
<?php if (!empty($attachments)): ?>
<h2 class="section-title" id="photos">照片与附件(<?= count($attachments) ?>)</h2>
<?php if (!empty($showPhotos)): ?>
<div class="photo-grid photo-grid-sm">
<?php foreach ($showPhotos as $attRow): ?>
<a class="photo-card" href="<?= e(media_att((int)$attRow['id'])) ?>"
target="_blank" rel="noopener" title="<?= e($attRow['file_name']) ?>">
<img src="<?= e(media_att((int)$attRow['id'])) ?>" alt="" loading="lazy">
</a>
<?php endforeach; ?>
<?php if (count($photoRows) > count($showPhotos)): ?>
<a class="photo-more" href="<?= url('attachments/node', ['id' => (int)$node['id']]) ?>#photos">
+<?= count($photoRows) - count($showPhotos) ?><span>张</span>
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="photo-bar">
<a class="btn btn-sm" href="<?= url('attachments/node', ['id' => (int)$node['id']]) ?>">
📷 管理照片(<?= count($attachments) ?>)
</a>
<?php if ($otherCount > 0): ?>
<span class="muted">另有 <?= (int)$otherCount ?> 个非图片附件</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_members.php'; ?>
<?php require __DIR__ . '/_expenses.php'; ?>
+310
View File
@@ -0,0 +1,310 @@
<?php
/**
* 存储设置(仅管理员)
*
* 两件事在这里定:照片存哪(本站 / 阿里云 OSS)、OSS 怎么鉴权(ECS 角色 / AccessKey)。
* 无论存哪里,页面里的照片地址都是本站的 media/show 代理 ——
* OSS 桶保持私有,前端拿不到、也不该拿到直链。
*/
$s = $s ?? [];
$isOss = ($s['storage_driver'] ?? 'local') === 'oss';
$auth = $s['oss_auth'] ?? 'ecs_role';
$hasSecret = (string)($s['oss_ak_secret'] ?? '') !== '';
$needMigrate = (int)$schemaNow < (int)$schemaTarget;
$locked = !$tableExists; // app_settings 还没建,保存必然失败
?>
<div class="page-header">
<div>
<h1>存储设置</h1>
<p class="muted">照片存哪里、怎么鉴权 —— 影响所有活动的附件</p>
</div>
<div class="actions">
<a href="<?= url('activities/index') ?>" class="btn">返回活动</a>
</div>
</div>
<div class="settings-status">
<div class="settings-chip <?= $driver === 'oss' ? 'ok' : '' ?>">
<div>当前生效</div>
<strong><?= e(\App\Storage::label($driver)) ?></strong>
<?php if ($misconfigured): ?>
<div class="text-danger">已选 OSS 但配置不全,实际回退到本站服务器</div>
<?php endif; ?>
</div>
<div class="settings-chip <?= $needMigrate ? 'warn' : 'ok' ?>">
<div>数据库结构</div>
<strong>v<?= (int)$schemaNow ?> / v<?= (int)$schemaTarget ?></strong>
<div><?= $needMigrate ? '需要执行迁移' : '已是最新' ?></div>
</div>
<div class="settings-chip <?= $isEcs ? 'ok' : 'warn' ?>">
<div>运行环境</div>
<strong><?= $isEcs ? '阿里云 ECS' : '非 ECS / 探测不到' ?></strong>
<div><?= $isEcs ? '角色:' . e(implode('、', $ecsRoles)) : 'ECS 角色鉴权需部署在阿里云 ECS 上' ?></div>
</div>
</div>
<?php if ($needMigrate): ?>
<div class="alert alert-warn">
数据库结构还是 v<?= (int)$schemaNow ?>,低于程序要求的 v<?= (int)$schemaTarget ?>。
请在服务器上以站点用户执行 <code>php tools/migrate.php</code>(CLI 专用,HTTP 访问不到)。
<?php if (!$tableExists): ?>
<br>当前 <code>app_settings</code> 表还不存在,本页「保存」会失败。
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="<?= url('settings/save') ?>" class="form" id="settings-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<div class="panel">
<h2 class="section-title">照片存放在哪里</h2>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="storage_driver" value="local" <?= $isOss ? '' : 'checked' ?>>
本站服务器
</label>
<label class="radio-label">
<input type="radio" name="storage_driver" value="oss" <?= $isOss ? 'checked' : '' ?>>
阿里云 OSS
</label>
</div>
<p class="settings-hint">
本站服务器:存到站点 <code>uploads/attachments/</code>,零配置。<br>
阿里云 OSS:桶保持 <b>私有</b>,读取由 PHP 代理转发。<br>
两种方式下页面里的照片地址都是 <code><?= e(url('media/show')) ?>?id=…</code>,看不出存储后端。
</p>
<div id="oss-fields" class="oss-fields<?= $isOss ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_endpoint">Endpoint <span class="req">*</span></label>
<input type="text" id="oss_endpoint" name="oss_endpoint"
value="<?= e($s['oss_endpoint'] ?? '') ?>"
placeholder="oss-cn-qingdao.aliyuncs.com" autocomplete="off">
<p class="settings-hint">只填域名,别带 https://,地域要与桶一致。</p>
</div>
<div class="form-row">
<label for="oss_bucket">Bucket 名称 <span class="req">*</span></label>
<input type="text" id="oss_bucket" name="oss_bucket"
value="<?= e($s['oss_bucket'] ?? '') ?>"
placeholder="my-bucket" autocomplete="off">
<p class="settings-hint">桶的读写权限请保持「私有」。</p>
</div>
<div class="form-row">
<label for="oss_prefix">对象前缀</label>
<input type="text" id="oss_prefix" name="oss_prefix"
value="<?= e($s['oss_prefix'] ?? '') ?>"
placeholder="triplog/" autocomplete="off">
<p class="settings-hint">相当于桶里的目录,末尾会自动补 <code>/</code>。</p>
</div>
<div class="form-row">
<label for="photo_max_mb">单张照片上限(MB)</label>
<input type="number" id="photo_max_mb" name="photo_max_mb" min="1" max="50"
value="<?= (int)($s['photo_max_mb'] ?? 12) ?>">
<p class="settings-hint">只限制图片;PDF 等其它附件仍按 20MB 处理。</p>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" name="oss_internal" value="1"
<?= (($s['oss_internal'] ?? '1') === '1') ? 'checked' : '' ?>>
<span>使用内网 Endpoint(服务器与桶同地域时勾选,省流量费)</span>
</label>
<p class="settings-hint">勾上后会自动把 <code>-internal</code> 插进域名。</p>
</div>
<h3 class="section-title">鉴权方式</h3>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="oss_auth" value="ecs_role" <?= $auth === 'ecs_role' ? 'checked' : '' ?>>
ECS 实例角色(推荐)
</label>
<label class="radio-label">
<input type="radio" name="oss_auth" value="access_key" <?= $auth === 'access_key' ? 'checked' : '' ?>>
AccessKey
</label>
</div>
<div id="ecs-fields" class="oss-fields<?= $auth === 'ecs_role' ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_role_name">实例 RAM 角色名 <span class="req">*</span></label>
<div class="inline-row">
<input type="text" id="oss_role_name" name="oss_role_name"
value="<?= e($s['oss_role_name'] ?? '') ?>"
placeholder="例如 SyncCalendarOssRole" autocomplete="off">
<button type="button" class="btn" id="btn-detect-roles">检测可用角色</button>
</div>
<?php if ($isEcs): ?>
<div class="role-chips" id="roles-result">
<?php foreach ($ecsRoles as $role): ?>
<button type="button" class="role-chip" data-role="<?= e($role) ?>"><?= e($role) ?></button>
<?php endforeach; ?>
</div>
<?php else: ?>
<div id="roles-result" class="settings-hint"></div>
<?php endif; ?>
<p class="settings-hint">
该角色需要对目标桶有读写权限。STS 临时凭证按分钟缓存在系统的 <code>/tmp</code>,
不会写进站点目录,也不会落到数据库。
</p>
</div>
</div>
<div id="ak-fields" class="oss-fields<?= $auth === 'access_key' ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_ak_id">AccessKeyId</label>
<input type="text" id="oss_ak_id" name="oss_ak_id"
value="<?= e($s['oss_ak_id'] ?? '') ?>"
placeholder="LTAI…" autocomplete="off">
</div>
<div class="form-row">
<label for="oss_ak_secret">AccessKeySecret</label>
<input type="password" id="oss_ak_secret" name="oss_ak_secret"
value="" autocomplete="new-password"
placeholder="<?= $hasSecret ? '(已保存,留空表示不修改)' : '填写 Secret' ?>">
<p class="settings-hint">
<?= $hasSecret ? '已保存一把密钥,留空即不修改。' : '尚未保存密钥。' ?>
能用 ECS 角色就别用这个 —— AccessKey 是以明文存进数据库的。
</p>
</div>
</div>
<div class="inline-row">
<button type="button" class="btn" id="btn-test">测试连接</button>
<span class="settings-hint">用表单里当前填的值实测「上传 → 读取 → 删除」,不必先保存。</span>
</div>
<div class="test-result" id="test-result"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" <?= $locked ? 'disabled' : '' ?>>保存设置</button>
<?php if ($locked): ?>
<span class="settings-hint">跑完迁移后才能保存。</span>
<?php endif; ?>
</div>
</form>
<script>
(function () {
'use strict';
var form = document.getElementById('settings-form');
var ossBox = document.getElementById('oss-fields');
var ecsBox = document.getElementById('ecs-fields');
var akBox = document.getElementById('ak-fields');
var testBox = document.getElementById('test-result');
var btnTest = document.getElementById('btn-test');
var rolesBox = document.getElementById('roles-result');
var csrf = <?= json_encode(csrf_token()) ?>;
function radioValue(name, fallback) {
var el = form.querySelector('input[name="' + name + '"]:checked');
return el ? el.value : fallback;
}
function sync() {
ossBox.classList.toggle('show', radioValue('storage_driver', 'local') === 'oss');
var auth = radioValue('oss_auth', 'ecs_role');
ecsBox.classList.toggle('show', auth === 'ecs_role');
akBox.classList.toggle('show', auth === 'access_key');
}
form.addEventListener('change', sync);
sync();
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showResult(ok, message, steps, extra) {
testBox.className = 'test-result show ' + (ok ? 'ok' : 'bad');
var lines = [(ok ? '✓ ' : '✕ ') + (message || '')];
if (extra) lines.push(extra);
(steps || []).forEach(function (step) {
var good = step.ok !== false;
lines.push((good ? ' ✓ ' : ' ✕ ') + (step.name || step.step || '')
+ (step.message ? ' — ' + step.message : ''));
});
testBox.textContent = lines.join('\n');
}
btnTest.addEventListener('click', function () {
btnTest.disabled = true;
testBox.className = 'test-result show';
testBox.textContent = '正在测试…';
var fd = new FormData(form);
fd.set('storage_driver', 'oss'); // 测试永远针对 OSS
fd.delete('_csrf'); // token 走请求头
var xhr = new XMLHttpRequest();
xhr.open('POST', <?= json_encode(url('settings/test')) ?>, true);
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onload = function () {
btnTest.disabled = false;
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) {}
if (!data) {
var text = String(xhr.responseText || '').replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ').trim().slice(0, 200);
showResult(false, '接口没返回 JSON(HTTP ' + xhr.status + '):' + text);
return;
}
showResult(!!data.ok, data.message, data.steps,
data.endpointResolved ? '实际请求地址:' + data.endpointResolved : '');
};
xhr.onerror = function () {
btnTest.disabled = false;
showResult(false, '网络错误,请求没能发出去');
};
xhr.send(fd);
});
document.getElementById('btn-detect-roles').addEventListener('click', function () {
var btn = this;
btn.disabled = true;
fetch(<?= json_encode(url('settings/roles')) ?>, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
btn.disabled = false;
var roles = (data && data.roles) || [];
if (!roles.length) {
rolesBox.innerHTML = '<span class="text-danger">没探测到角色。</span> '
+ '本机可能不是阿里云 ECS,或元数据服务被挡住了 —— 这种情况请改用 AccessKey。';
rolesBox.className = 'role-chips';
return;
}
var input = document.getElementById('oss_role_name');
if (!input.value) input.value = roles[0];
rolesBox.className = 'role-chips';
rolesBox.innerHTML = roles.map(function (r) {
return '<button type="button" class="role-chip" data-role="' + esc(r) + '">'
+ esc(r) + '</button>';
}).join('');
})
.catch(function (e) {
btn.disabled = false;
rolesBox.className = 'role-chips';
rolesBox.innerHTML = '<span class="text-danger">探测失败:</span>' + esc(e.message);
});
});
rolesBox.addEventListener('click', function (ev) {
var chip = ev.target.closest ? ev.target.closest('button[data-role]') : null;
if (!chip) return;
document.getElementById('oss_role_name').value = chip.getAttribute('data-role');
});
})();
</script>