交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - 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:
+125
-108
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 { '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|
||||
|
||||
Reference in New Issue
Block a user