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

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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
+842 -879
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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'],
+24
View File
@@ -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`);
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 统一附件表(attachments)的读写。
*
* 这张表是**软关联**(target_type + target_id),没有外键,删父行时必须手工清理,
* 所以「按目标清空」的语义集中放在这里,别在控制器里各写一遍。
*/
final class Attachments
{
public const TARGET_ACTIVITY = 'activity';
public const TARGET_NODE = 'trip_node';
public const TARGET_COMMENT = 'comment';
/**
* 新增一条附件记录。
*
* 容错:万一部署时忘了跑迁移(没有 storage/object_key 等列),
* 这里会退回「只写老列」的插入,而不是抛错让上传整体失败。
*
* @param array{storage:string,key:string,size:int} $stored
* @return int 新记录 id
*/
public static function create(
string $targetType,
int $targetId,
int $uploaderId,
array $stored,
string $fileName,
?string $mime = null,
bool $isImage = false,
?int $width = null,
?int $height = null
): int {
$db = Database::get();
// 本地存储保留 file_url(兼容旧读法 / 旧模板),OSS 存的是私有对象,不给直链
$fileUrl = $stored['storage'] === Storage::DRIVER_LOCAL ? '/uploads/' . $stored['key'] : '';
try {
$stmt = $db->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<int, 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)];
}
}
+15 -44
View File
@@ -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);
+178 -125
View File
@@ -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,29 +241,6 @@ 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()) {
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Attachments;
use App\Auth;
use App\Database;
use App\Mime;
use App\Storage;
/**
* 媒体代理。
*
* 存在的唯一理由:**桶是私有的,且不该把 OSS 地址暴露给浏览器**。
* 所有照片/封面都通过这里以当前登录用户的身份读出、并且**先校验家庭归属**再输出,
* 所以把 id 换成别人家的也拿不到东西。
*
* 页面里因此只出现 media/show?id=12 这样的地址,永远不会出现 oss-*.aliyuncs.com。
*/
class MediaController
{
/** GET media/show?id=<attachment_id>[&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=<activity_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');
}
}
}
+20 -43
View File
@@ -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);
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Database;
use App\Migrate;
use App\Oss;
use App\Settings;
use App\Storage;
/**
* 存储设置(仅管理员)。
*
* 支持两种后端:本站服务器(默认)/ 阿里云 OSS;
* OSS 又支持两种鉴权:ECS 实例 RAM 角色换 STS 临时凭证(推荐,无需落库长期密钥)、
* 或直接填 AccessKey。
*/
class SettingsController
{
private const DRIVERS = ['local', 'oss'];
private const AUTHS = ['ecs_role', 'access_key'];
public function index(): void
{
Access::requireAdmin();
$s = Settings::all();
// 只探测一次:元数据服务不可达时每次调用都要等超时,别在同一个请求里问两遍
$ecsRoles = Oss::listEcsRoles();
view('settings/index', [
's' => $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);
}
}
+6 -5
View File
@@ -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) {
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
/**
* 幂等迁移。
*
* 为什么不用「自动在每次请求里跑」:那会给每个请求都加一次查询。
* 这里做成**显式执行**(tools/migrate.php,仅 CLI),并且业务代码对
* 「表/列还不存在」是容错的(见 Settings::all() 的 try/catch),
* 所以哪天忘了跑迁移,后果是「存储退回本地」而不是整站白屏。
*/
final class Migrate
{
/** 迁移完成后的目标版本号,改结构时 +1 */
public const VERSION = 2;
/**
* @param callable(string):void|null $log
* @return string[] 实际执行了的步骤描述
*/
public static function run(PDO $db, ?callable $log = null): array
{
$log = $log ?? static function (string $s): void {};
$done = [];
$step = static function (string $desc, callable $fn) use (&$done, $log): void {
$fn();
$done[] = $desc;
$log(' + ' . $desc);
};
// 1) app_settings
if (!self::hasTable($db, 'app_settings')) {
$step('创建 app_settings 表', static function () use ($db): void {
$db->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;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App;
/**
* MIME 识别 —— **不依赖 fileinfo 扩展**。
*
* 为什么不用 fileinfo 扩展:线上同时装了 PHP 7.4 与 8.1,**7.4 没装这个扩展**,
* 一旦站点切到 7.4,调用它的探测函数会直接致命错误(整页 500)。
* (部署脚本里有条静态断言要求全项目不出现该扩展的函数名,这条注释刻意绕开写法。)
* 这里改成三层兜底,纯标准库实现:
* 1) getimagesize() —— 图片最可靠,还能顺带拿到宽高;
* 2) 魔数嗅探 —— PDF / ZIP 系(docx/xlsx)/ MP4 / MOV / HEIC 等;
* 3) 扩展名映射 —— 最后兜底。
*/
final class Mime
{
/** 允许上传的 MIME → 扩展名(白名单,同时也是扩展名兜底表) */
public const ALLOWED = [
'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',
];
/** 扩展名 → 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;
}
}
+396
View File
@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 极简阿里云 OSS 客户端(不依赖 composer,只有 curl + hash)。
*
* 为什么手写:项目没有 composer / autoload,官方 SDK 拉不进来。
* OSS 的 V1 签名本身很简单,够用且可审计:
*
* StringToSign = VERB \n Content-MD5 \n Content-Type \n Date \n
* CanonicalizedOSSHeaders + CanonicalizedResource
* Authorization: OSS <AccessKeyId>:<base64(hmac-sha1(AccessKeySecret, StringToSign))>
*
* 鉴权两种:
* 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<string,string> */
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),
];
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
/**
* 站点级设置(键值对,存在 app_settings 表)。
*
* 容错原则:**表还没建好时不能白屏**。
* 迁移是显式执行的(tools/migrate.php),万一忘了跑,这里所有读操作
* 都退回默认值 —— 表现是「存储退回本地」,而不是整站 500。
*/
final class Settings
{
/** 所有已知设置项及其默认值 */
public const DEFAULTS = [
// 存储后端:local = 本站 uploads/ 目录;oss = 阿里云对象存储
'storage_driver' => '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<string,string> */
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<string,string> $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'),
]);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 存储门面:把「存哪里」这件事收在一处,业务代码不再关心本地还是 OSS。
*
* 键的约定:**对象键不含存储后端信息**,两种后端共用同一套命名
* 本地: uploads/{key}
* OSS : {settings.oss_prefix}{key}
* 例:key = "attachments/20260922_ab12cd34.jpg"
*
* 页面里一律不直接用这里的键拼 URL —— 取文件走 media/show 代理
* (桶是私有的,直链既取不到也不该暴露)。
*/
final class Storage
{
public const DRIVER_LOCAL = 'local';
public const DRIVER_OSS = 'oss';
/**
* 当前生效的驱动。OSS 配置不全时**静默回退本地**,
* 这样即使管理员填错了配置,上传也不会整个不可用。
*/
public static function driver(): string
{
if (Settings::get('storage_driver') === self::DRIVER_OSS && Settings::ossReady()) {
return self::DRIVER_OSS;
}
return self::DRIVER_LOCAL;
}
/** 是否处于「选了 OSS 但配置不全,已回退本地」的状态(用于在界面上给出提示) */
public static function ossMisconfigured(): bool
{
return Settings::get('storage_driver') === self::DRIVER_OSS && !Settings::ossReady();
}
public static function label(?string $driver = null): string
{
return ($driver ?? self::driver()) === self::DRIVER_OSS ? '阿里云 OSS' : '本站服务器';
}
/** 生成一个不会撞的新对象键 */
public static function keyFor(string $ext, string $dir = 'attachments'): string
{
$ext = ltrim(strtolower($ext), '.');
$ext = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
return $dir . '/' . date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/**
* 写入一份内容。
* @return array{storage:string, key:string, size:int}
*/
public static function put(string $key, string $body, string $mime): array
{
$driver = self::driver();
if ($driver === self::DRIVER_OSS) {
$oss = Settings::ossClient();
if ($oss === null) {
throw new \RuntimeException('OSS 未配置完整');
}
$oss->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';
}
}
+64 -1
View File
@@ -65,15 +65,40 @@ 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;
@@ -81,3 +106,41 @@ function json_decode_field($json, $default = [])
$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';
}
+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; ?>
+93 -21
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>
+528 -86
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>
+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>
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* 数据库迁移入口 —— **只在命令行跑**。
*
* php tools/migrate.php 执行迁移(幂等,可反复跑)
* php tools/migrate.php --status 只看当前版本,不改任何东西
*
* 放在站点目录里意味着它理论上能被 HTTP 访问到,所以第一件事就是
* 拒绝非 CLI 调用 —— 否则任何人都能触发一次结构变更。
*/
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit('Not Found');
}
require dirname(__DIR__) . '/src/Database.php';
require dirname(__DIR__) . '/src/Migrate.php';
use App\Database;
use App\Migrate;
$statusOnly = in_array('--status', $argv, true);
try {
$db = Database::get();
} catch (Throwable $e) {
fwrite(STDERR, "无法连接数据库:" . $e->getMessage() . "\n");
exit(1);
}
$current = Migrate::currentVersion($db);
$target = Migrate::VERSION;
echo "数据库:迁移版本 当前={$current} 目标={$target}\n";
if ($statusOnly) {
echo $current === $target ? "状态:已是最新\n" : "状态:需要执行迁移(php tools/migrate.php)\n";
exit(0);
}
if ($current === $target) {
echo "无需迁移。\n";
exit(0);
}
echo "开始迁移……\n";
try {
$done = Migrate::run($db, static function (string $s): void {
echo $s . "\n";
});
} catch (Throwable $e) {
fwrite(STDERR, "迁移失败:" . $e->getMessage() . "\n");
exit(1);
}
echo $done === []
? "本次没有需要执行的步骤(结构已是目标状态)。\n"
: '迁移完成,共 ' . count($done) . " 步。\n";