交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - 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:
+843
-880
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,12 @@ require __DIR__ . '/src/Comments.php';
|
|||||||
require __DIR__ . '/src/Members.php';
|
require __DIR__ . '/src/Members.php';
|
||||||
require __DIR__ . '/src/Expenses.php';
|
require __DIR__ . '/src/Expenses.php';
|
||||||
require __DIR__ . '/src/Access.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/AuthController.php';
|
||||||
require __DIR__ . '/src/Controllers/AttachmentController.php';
|
require __DIR__ . '/src/Controllers/AttachmentController.php';
|
||||||
require __DIR__ . '/src/Controllers/ActivityController.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/MemberController.php';
|
||||||
require __DIR__ . '/src/Controllers/CommentController.php';
|
require __DIR__ . '/src/Controllers/CommentController.php';
|
||||||
require __DIR__ . '/src/Controllers/ExpenseController.php';
|
require __DIR__ . '/src/Controllers/ExpenseController.php';
|
||||||
|
require __DIR__ . '/src/Controllers/MediaController.php';
|
||||||
|
require __DIR__ . '/src/Controllers/SettingsController.php';
|
||||||
|
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Controllers\AuthController;
|
use App\Controllers\AuthController;
|
||||||
@@ -34,6 +42,8 @@ use App\Controllers\RoleTagController;
|
|||||||
use App\Controllers\MemberController;
|
use App\Controllers\MemberController;
|
||||||
use App\Controllers\CommentController;
|
use App\Controllers\CommentController;
|
||||||
use App\Controllers\ExpenseController;
|
use App\Controllers\ExpenseController;
|
||||||
|
use App\Controllers\MediaController;
|
||||||
|
use App\Controllers\SettingsController;
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
date_default_timezone_set('Asia/Shanghai');
|
date_default_timezone_set('Asia/Shanghai');
|
||||||
@@ -69,6 +79,16 @@ $map = [
|
|||||||
'attachments/destroy' => [AttachmentController::class, 'destroy'],
|
'attachments/destroy' => [AttachmentController::class, 'destroy'],
|
||||||
'activities/uploadCover' => [ActivityController::class, 'uploadCover'],
|
'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/create' => [NodeController::class, 'create'],
|
||||||
'nodes/store' => [NodeController::class, 'store'],
|
'nodes/store' => [NodeController::class, 'store'],
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Attachments;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Comments;
|
use App\Comments;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Expenses;
|
use App\Expenses;
|
||||||
|
use App\Mime;
|
||||||
|
|
||||||
class ActivityController
|
class ActivityController
|
||||||
{
|
{
|
||||||
@@ -143,32 +145,17 @@ class ActivityController
|
|||||||
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
|
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
|
||||||
$id = (int)$activity['id'];
|
$id = (int)$activity['id'];
|
||||||
|
|
||||||
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清
|
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清。
|
||||||
$files = [];
|
// 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 ——
|
||||||
$stmt = $db->prepare('SELECT id, file_url FROM attachments
|
// 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。
|
||||||
WHERE target_type = "activity" AND target_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$files[] = $row['file_url'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
|
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
||||||
if (!empty($nodeIds)) {
|
|
||||||
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
|
Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id);
|
||||||
$stmt = $db->prepare("SELECT file_url FROM attachments
|
foreach ($nodeIds as $nodeId) {
|
||||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId);
|
||||||
$stmt->execute($nodeIds);
|
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$files[] = $row['file_url'];
|
|
||||||
}
|
}
|
||||||
$stmt = $db->prepare("DELETE FROM attachments
|
|
||||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
|
||||||
$stmt->execute($nodeIds);
|
|
||||||
}
|
|
||||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// 评论同样是软关联
|
// 评论同样是软关联
|
||||||
if (!empty($nodeIds)) {
|
if (!empty($nodeIds)) {
|
||||||
@@ -183,12 +170,7 @@ class ActivityController
|
|||||||
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
|
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
|
||||||
$stmt->execute([$id, Auth::familyId()]);
|
$stmt->execute([$id, Auth::familyId()]);
|
||||||
|
|
||||||
// 数据库删干净了再删物理文件,避免删早了但事务回滚留下破损记录
|
// 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了
|
||||||
// 注意:附件文件在 /uploads/attachments/ 下,不能拿「只认 covers」的
|
|
||||||
// deleteCoverFile() 去删 —— 那样判定不通过,文件会变成永远删不掉的孤儿
|
|
||||||
foreach ($files as $url) {
|
|
||||||
$this->deleteUploadFile((string)$url);
|
|
||||||
}
|
|
||||||
if (!empty($activity['cover_image'])) {
|
if (!empty($activity['cover_image'])) {
|
||||||
$this->deleteCoverFile((string)$activity['cover_image']);
|
$this->deleteCoverFile((string)$activity['cover_image']);
|
||||||
}
|
}
|
||||||
@@ -290,14 +272,14 @@ class ActivityController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
// 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底)
|
||||||
$mime = finfo_buffer($finfo, $raw);
|
$info = Mime::detectBuffer($raw);
|
||||||
finfo_close($finfo);
|
if ($info['mime'] === null || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$ext = Mime::extFor($info['mime']) ?? $ext;
|
||||||
|
|
||||||
$dir = dirname(__DIR__, 2) . '/uploads/covers';
|
$dir = dirname(__DIR__, 2) . '/uploads/covers';
|
||||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
@@ -353,18 +335,7 @@ class ActivityController
|
|||||||
return $url;
|
return $url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只删除本站上传目录下的文件(封面 / 附件),其余一律不碰 */
|
/** 只删除本站 uploads/covers/ 下的封面文件 */
|
||||||
private function deleteUploadFile(string $url): void
|
|
||||||
{
|
|
||||||
$url = trim($url);
|
|
||||||
if ($url === '' || strpos($url, '..') !== false) return;
|
|
||||||
if (strpos($url, '/uploads/covers/') !== 0 && strpos($url, '/uploads/attachments/') !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$file = dirname(__DIR__, 2) . $url;
|
|
||||||
if (is_file($file)) @unlink($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function deleteCoverFile(string $url): void
|
private function deleteCoverFile(string $url): void
|
||||||
{
|
{
|
||||||
$url = trim($url);
|
$url = trim($url);
|
||||||
|
|||||||
@@ -3,149 +3,225 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Access;
|
||||||
|
use App\Attachments;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Database;
|
use App\Mime;
|
||||||
|
use App\Settings;
|
||||||
|
use App\Storage;
|
||||||
|
|
||||||
class AttachmentController
|
class AttachmentController
|
||||||
{
|
{
|
||||||
private const ALLOWED_MIMES = [
|
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||||
'image/jpeg' => 'jpg',
|
private const MAX_FILES = 30;
|
||||||
'image/png' => 'png',
|
|
||||||
'image/gif' => 'gif',
|
|
||||||
'image/webp' => 'webp',
|
|
||||||
'image/heic' => 'heic',
|
|
||||||
'application/pdf' => 'pdf',
|
|
||||||
'application/msword' => 'doc',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
|
||||||
'application/vnd.ms-excel' => 'xls',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
|
||||||
'application/zip' => 'zip',
|
|
||||||
'video/mp4' => 'mp4',
|
|
||||||
'video/quicktime' => 'mov',
|
|
||||||
];
|
|
||||||
|
|
||||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
|
||||||
|
|
||||||
public function node(): void
|
public function node(): void
|
||||||
{
|
{
|
||||||
$this->requireLogin();
|
$this->requireLogin();
|
||||||
$nodeId = (int)($_GET['id'] ?? 0);
|
$nodeId = (int)($_GET['id'] ?? 0);
|
||||||
$node = $this->fetchNodeWithAccess($nodeId);
|
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||||||
|
|
||||||
$db = Database::get();
|
|
||||||
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
|
|
||||||
FROM attachments a
|
|
||||||
LEFT JOIN users u ON u.id = a.uploader_id
|
|
||||||
WHERE a.target_type = "trip_node" AND a.target_id = ?
|
|
||||||
ORDER BY a.created_at DESC');
|
|
||||||
$stmt->execute([$nodeId]);
|
|
||||||
$attachments = $stmt->fetchAll();
|
|
||||||
|
|
||||||
view('nodes/attachments', [
|
view('nodes/attachments', [
|
||||||
'node' => $node,
|
'node' => $node,
|
||||||
'attachments' => $attachments,
|
'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId),
|
||||||
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
|
'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
|
public function upload(): void
|
||||||
{
|
{
|
||||||
$this->requireLogin();
|
$this->requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
|
|
||||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||||
$this->fetchNodeWithAccess($nodeId);
|
$node = Access::node($nodeId);
|
||||||
|
|
||||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
$maxBytes = 20 * 1024 * 1024;
|
||||||
flash('error', '未选择文件');
|
$photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
||||||
redirect('attachments/node', ['id' => $nodeId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = $this->restructureFiles($_FILES['files']);
|
|
||||||
$uploaded = 0;
|
$uploaded = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
$db = Database::get();
|
// ── 1) 普通文件 ──
|
||||||
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
|
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
foreach ($this->restructureFiles($_FILES['files']) as $file) {
|
||||||
|
if ($count >= self::MAX_FILES) {
|
||||||
|
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
$errors[] = $file['name'] . ': 上传错误';
|
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($file['size'] > self::MAX_SIZE) {
|
$limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes;
|
||||||
$errors[] = $file['name'] . ': 超过 20MB';
|
if ($file['size'] > $limit) {
|
||||||
|
$errors[] = $file['name'] . ':超过大小上限';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
$mime = $info['mime'];
|
||||||
finfo_close($finfo);
|
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||||
|
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||||
if (!isset(self::ALLOWED_MIMES[$mime])) {
|
continue;
|
||||||
$errors[] = $file['name'] . ': 不支持的类型';
|
}
|
||||||
|
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
||||||
|
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = self::ALLOWED_MIMES[$mime];
|
$raw = @file_get_contents($file['tmp_name']);
|
||||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
if ($raw === false) {
|
||||||
$dest = $uploadDir . '/' . $filename;
|
$errors[] = $file['name'] . ':读取临时文件失败';
|
||||||
|
|
||||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
|
||||||
$errors[] = $file['name'] . ': 保存失败';
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare('INSERT INTO attachments
|
$err = $this->store($nodeId, $raw, $mime, $file['name'], $info);
|
||||||
(target_type, target_id, uploader_id, file_url, file_name)
|
if ($err === null) {
|
||||||
VALUES ("trip_node", ?, ?, ?, ?)');
|
|
||||||
$stmt->execute([
|
|
||||||
$nodeId,
|
|
||||||
Auth::id(),
|
|
||||||
'/uploads/attachments/' . $filename,
|
|
||||||
$file['name'],
|
|
||||||
]);
|
|
||||||
$uploaded++;
|
$uploaded++;
|
||||||
|
} else {
|
||||||
|
$errors[] = $file['name'] . ':' . $err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$msg = "成功上传 {$uploaded} 个文件";
|
// ── 2) 前端裁剪后的图片 ──
|
||||||
if (!empty($errors)) {
|
$cropped = $_POST['cropped'] ?? '';
|
||||||
$msg .= ';失败:' . implode(',', $errors);
|
if (is_string($cropped) && $cropped !== '') {
|
||||||
|
$items = json_decode($cropped, true);
|
||||||
|
if (!is_array($items)) {
|
||||||
|
$errors[] = '裁剪数据解析失败';
|
||||||
|
$items = [];
|
||||||
}
|
}
|
||||||
flash('error', $msg);
|
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]);
|
redirect('attachments/node', ['id' => $nodeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')';
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5));
|
||||||
|
}
|
||||||
|
if (Storage::ossMisconfigured()) {
|
||||||
|
$msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器';
|
||||||
|
}
|
||||||
|
|
||||||
|
flash('error', $msg);
|
||||||
|
redirect('attachments/node', ['id' => $nodeId], 'photos');
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(): void
|
public function destroy(): void
|
||||||
{
|
{
|
||||||
$this->requireLogin();
|
$this->requireLogin();
|
||||||
csrf_check();
|
csrf_check();
|
||||||
|
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$db = Database::get();
|
$att = Attachments::find($id);
|
||||||
|
if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) {
|
||||||
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$att = $stmt->fetch();
|
|
||||||
if (!$att) {
|
|
||||||
flash('error', '附件不存在');
|
flash('error', '附件不存在');
|
||||||
redirect('activities/index');
|
redirect('activities/index');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->fetchNodeWithAccess((int)$att['target_id']);
|
$node = Access::node((int)$att['target_id']); // 顺带做归属校验
|
||||||
|
Attachments::delete($id);
|
||||||
|
|
||||||
$url = $att['file_url'];
|
flash('error', '照片已删除');
|
||||||
if (strpos($url, '/uploads/attachments/') === 0) {
|
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
||||||
$file = dirname(__DIR__, 2) . $url;
|
|
||||||
if (is_file($file)) @unlink($file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
|
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
||||||
$stmt->execute([$id]);
|
private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string
|
||||||
|
{
|
||||||
|
$ext = Mime::extFor($mime);
|
||||||
|
if ($ext === null) {
|
||||||
|
return '无法确定扩展名';
|
||||||
|
}
|
||||||
|
|
||||||
flash('error', '附件已删除');
|
try {
|
||||||
redirect('attachments/node', ['id' => (int)$att['target_id']]);
|
$key = Storage::keyFor($ext, 'attachments');
|
||||||
|
$stored = Storage::put($key, $raw, $mime);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
private function restructureFiles(array $files): array
|
||||||
@@ -165,29 +241,6 @@ class AttachmentController
|
|||||||
return $result;
|
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
|
private function requireLogin(): void
|
||||||
{
|
{
|
||||||
if (!Auth::check()) {
|
if (!Auth::check()) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Access;
|
use App\Access;
|
||||||
|
use App\Attachments;
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Comments;
|
use App\Comments;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
@@ -59,18 +60,20 @@ class NodeController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $input['data'];
|
$data = $input['data'];
|
||||||
|
// 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司,
|
||||||
|
// 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。
|
||||||
$sql = 'INSERT INTO trip_nodes
|
$sql = 'INSERT INTO trip_nodes
|
||||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||||
start_time, end_time, duration_minutes,
|
start_time, end_time, duration_minutes,
|
||||||
start_location, end_location,
|
start_location, end_location,
|
||||||
title, description, travel_mode_id, vehicle_id,
|
title, description, travel_mode_id, vehicle_id,
|
||||||
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
|
trip_number, status, cancel_reason)
|
||||||
VALUES
|
VALUES
|
||||||
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
|
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
|
||||||
:start_time, :end_time, :duration_minutes,
|
:start_time, :end_time, :duration_minutes,
|
||||||
:start_location, :end_location,
|
:start_location, :end_location,
|
||||||
:title, :description, :travel_mode_id, :vehicle_id,
|
: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 = $db->prepare($sql);
|
||||||
$stmt->execute($data);
|
$stmt->execute($data);
|
||||||
|
|
||||||
@@ -106,8 +109,7 @@ class NodeController
|
|||||||
start_location = :start_location, end_location = :end_location,
|
start_location = :start_location, end_location = :end_location,
|
||||||
title = :title, description = :description,
|
title = :title, description = :description,
|
||||||
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
|
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
|
||||||
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
|
trip_number = :trip_number, status = :status, cancel_reason = :cancel_reason
|
||||||
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
|
|
||||||
WHERE id = :id';
|
WHERE id = :id';
|
||||||
$stmt = $db->prepare($sql);
|
$stmt = $db->prepare($sql);
|
||||||
$stmt->execute($data);
|
$stmt->execute($data);
|
||||||
@@ -174,8 +176,8 @@ class NodeController
|
|||||||
start_time, end_time, duration_minutes,
|
start_time, end_time, duration_minutes,
|
||||||
start_location, end_location,
|
start_location, end_location,
|
||||||
title, description, travel_mode_id, vehicle_id,
|
title, description, travel_mode_id, vehicle_id,
|
||||||
line_name, carrier, trip_number, seat_info, gate, status)
|
trip_number, status)
|
||||||
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
(int)$plan['activity_id'],
|
(int)$plan['activity_id'],
|
||||||
(int)$plan['node_type_id'],
|
(int)$plan['node_type_id'],
|
||||||
@@ -189,11 +191,7 @@ class NodeController
|
|||||||
$plan['description'],
|
$plan['description'],
|
||||||
$plan['travel_mode_id'],
|
$plan['travel_mode_id'],
|
||||||
$plan['vehicle_id'],
|
$plan['vehicle_id'],
|
||||||
$plan['line_name'],
|
|
||||||
$plan['carrier'],
|
|
||||||
$plan['trip_number'],
|
$plan['trip_number'],
|
||||||
$plan['seat_info'],
|
|
||||||
$plan['gate'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
|
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
|
||||||
@@ -207,14 +205,8 @@ class NodeController
|
|||||||
|
|
||||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||||
|
|
||||||
// 软关联的附件先查出来,删完数据再删文件
|
// 附件是软关联:交给 Attachments 统一清(同时会删本地文件或 OSS 对象)
|
||||||
$stmt = $db->prepare('SELECT file_url FROM attachments
|
Attachments::purgeTarget(Attachments::TARGET_NODE, (int)$node['id']);
|
||||||
WHERE target_type = "trip_node" AND target_id = ?');
|
|
||||||
$stmt->execute([(int)$node['id']]);
|
|
||||||
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
|
||||||
|
|
||||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
|
|
||||||
$stmt->execute([(int)$node['id']]);
|
|
||||||
|
|
||||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
|
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
|
||||||
$stmt->execute([(int)$node['id']]);
|
$stmt->execute([(int)$node['id']]);
|
||||||
@@ -223,10 +215,6 @@ class NodeController
|
|||||||
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
|
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
|
||||||
$stmt->execute([(int)$node['id']]);
|
$stmt->execute([(int)$node['id']]);
|
||||||
|
|
||||||
foreach ($files as $url) {
|
|
||||||
$this->deleteAttachmentFile((string)$url);
|
|
||||||
}
|
|
||||||
|
|
||||||
flash('error', '节点已删除');
|
flash('error', '节点已删除');
|
||||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||||
}
|
}
|
||||||
@@ -249,6 +237,7 @@ class NodeController
|
|||||||
|
|
||||||
// 出行方式(连同车辆信息拼成一行展示文本)
|
// 出行方式(连同车辆信息拼成一行展示文本)
|
||||||
$travelModeLabel = '';
|
$travelModeLabel = '';
|
||||||
|
$travelModeType = '';
|
||||||
if (!empty($node['travel_mode_id'])) {
|
if (!empty($node['travel_mode_id'])) {
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT tm.mode_type, v.plate_number, v.model_name
|
SELECT tm.mode_type, v.plate_number, v.model_name
|
||||||
@@ -264,18 +253,13 @@ class NodeController
|
|||||||
} elseif (!empty($tm['model_name'])) {
|
} elseif (!empty($tm['model_name'])) {
|
||||||
$label .= ' · ' . $tm['model_name'];
|
$label .= ' · ' . $tm['model_name'];
|
||||||
}
|
}
|
||||||
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
|
|
||||||
if (!empty($node['line_name'])) {
|
|
||||||
$label .= ' · ' . $node['line_name'];
|
|
||||||
}
|
|
||||||
$travelModeLabel = $label;
|
$travelModeLabel = $label;
|
||||||
|
$travelModeType = (string)$tm['mode_type'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
|
$nodeAttachments = Attachments::forTarget(Attachments::TARGET_NODE, $nodeId);
|
||||||
WHERE target_type = "trip_node" AND target_id = ?');
|
$attachmentCount = count($nodeAttachments);
|
||||||
$stmt->execute([$nodeId]);
|
|
||||||
$attachmentCount = (int)$stmt->fetchColumn();
|
|
||||||
|
|
||||||
$familyId = (int)$node['family_id'];
|
$familyId = (int)$node['family_id'];
|
||||||
|
|
||||||
@@ -284,8 +268,10 @@ class NodeController
|
|||||||
'node_type_name' => (string)$type['name'],
|
'node_type_name' => (string)$type['name'],
|
||||||
'node_type_icon' => (string)$type['icon'],
|
'node_type_icon' => (string)$type['icon'],
|
||||||
'travel_mode_label' => $travelModeLabel,
|
'travel_mode_label' => $travelModeLabel,
|
||||||
|
'travel_mode_type' => $travelModeType,
|
||||||
]),
|
]),
|
||||||
'attachmentCount' => $attachmentCount,
|
'attachmentCount' => $attachmentCount,
|
||||||
|
'attachments' => $nodeAttachments,
|
||||||
'members' => Members::forNode($nodeId),
|
'members' => Members::forNode($nodeId),
|
||||||
'familyUsers' => Members::familyUsers($familyId),
|
'familyUsers' => Members::familyUsers($familyId),
|
||||||
'roleTags' => Members::roleTagMap($familyId),
|
'roleTags' => Members::roleTagMap($familyId),
|
||||||
@@ -414,11 +400,11 @@ class NodeController
|
|||||||
'description' => trim($_POST['description'] ?? ''),
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
'travel_mode_id' => $travelModeId,
|
'travel_mode_id' => $travelModeId,
|
||||||
'vehicle_id' => $vehicleId,
|
'vehicle_id' => $vehicleId,
|
||||||
'line_name' => $this->strOrNull('line_name'),
|
'line_name' => null,
|
||||||
'carrier' => $this->strOrNull('carrier'),
|
'carrier' => null,
|
||||||
'trip_number' => $this->strOrNull('trip_number'),
|
'trip_number' => $this->strOrNull('trip_number'),
|
||||||
'seat_info' => $this->strOrNull('seat_info'),
|
'seat_info' => null,
|
||||||
'gate' => $this->strOrNull('gate'),
|
'gate' => null,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
|
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
|
||||||
];
|
];
|
||||||
@@ -473,15 +459,6 @@ class NodeController
|
|||||||
return $node;
|
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
|
private function calcDuration(string $start, string $end): int
|
||||||
{
|
{
|
||||||
$s = strtotime($start);
|
$s = strtotime($start);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
|||||||
|
|
||||||
use App\Auth;
|
use App\Auth;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
|
use App\Mime;
|
||||||
|
|
||||||
class UserController
|
class UserController
|
||||||
{
|
{
|
||||||
@@ -353,7 +354,6 @@ class UserController
|
|||||||
$ext = strtolower($m[1]);
|
$ext = strtolower($m[1]);
|
||||||
if ($ext === 'jpeg') $ext = 'jpg';
|
if ($ext === 'jpeg') $ext = 'jpg';
|
||||||
$raw = base64_decode($m[2], true);
|
$raw = base64_decode($m[2], true);
|
||||||
|
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
|
||||||
@@ -366,9 +366,9 @@ class UserController
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
// 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底)
|
||||||
$mime = finfo_buffer($finfo, $raw);
|
$info = Mime::detectBuffer($raw);
|
||||||
finfo_close($finfo);
|
$mime = $info['mime'] ?? '';
|
||||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||||
@@ -378,7 +378,8 @@ class UserController
|
|||||||
$dir = dirname(__DIR__, 2) . '/uploads/avatars';
|
$dir = dirname(__DIR__, 2) . '/uploads/avatars';
|
||||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
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;
|
$savePath = $dir . '/' . $filename;
|
||||||
|
|
||||||
if (file_put_contents($savePath, $raw) === false) {
|
if (file_put_contents($savePath, $raw) === false) {
|
||||||
|
|||||||
+119
@@ -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
@@ -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
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -65,15 +65,40 @@ function csrf_token(): string
|
|||||||
return $_SESSION['_csrf'];
|
return $_SESSION['_csrf'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 CSRF。两个通道都收:
|
||||||
|
* 表单 → 隐藏字段 _csrf
|
||||||
|
* XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body)
|
||||||
|
*/
|
||||||
function csrf_check(): void
|
function csrf_check(): void
|
||||||
{
|
{
|
||||||
$token = $_POST['_csrf'] ?? '';
|
$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);
|
http_response_code(419);
|
||||||
exit('CSRF token 校验失败');
|
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 = [])
|
function json_decode_field($json, $default = [])
|
||||||
{
|
{
|
||||||
if (empty($json)) return $default;
|
if (empty($json)) return $default;
|
||||||
@@ -81,3 +106,41 @@ function json_decode_field($json, $default = [])
|
|||||||
$decoded = json_decode($json, true);
|
$decoded = json_decode($json, true);
|
||||||
return $decoded ?? $default;
|
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';
|
||||||
|
}
|
||||||
@@ -20,12 +20,10 @@ $isExecuted = $cardCategory === 'plan' && in_array((int)$n['id'], $executedPlanI
|
|||||||
$sLoc = json_decode_field($n['start_location'] ?? null);
|
$sLoc = json_decode_field($n['start_location'] ?? null);
|
||||||
$eLoc = json_decode_field($n['end_location'] ?? null);
|
$eLoc = json_decode_field($n['end_location'] ?? null);
|
||||||
|
|
||||||
|
// 交通只记一个号码(航班号 / 车次 / 班次)—— 航班号本身就隐含航空公司,
|
||||||
|
// 座位号对「多人同乘一趟」没有意义,所以这些字段都已不再收集
|
||||||
$transportParts = [];
|
$transportParts = [];
|
||||||
if (!empty($n['trip_number'])) $transportParts[] = $n['trip_number'];
|
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;
|
$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('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">
|
<a href="<?= url('attachments/node', ['id' => $n['id']]) ?>" class="node-action-link">
|
||||||
📎 附件 (<?= (int)($n['attachment_count'] ?? 0) ?>)
|
📷 照片 (<?= (int)($n['attachment_count'] ?? 0) ?>)
|
||||||
</a>
|
</a>
|
||||||
<?php if ((int)($n['member_count'] ?? 0) > 0): ?>
|
<?php if ((int)($n['member_count'] ?? 0) > 0): ?>
|
||||||
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#members" class="node-action-link">
|
<a href="<?= url('nodes/show', ['id' => $n['id']]) ?>#members" class="node-action-link">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
$isEdit = $activity !== null;
|
$isEdit = $activity !== null;
|
||||||
$cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
|
$cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
|
||||||
|
// 预览也走媒体代理:页面里不出现 /uploads/... 真实路径
|
||||||
|
$coverPreview = ($isEdit && $cover !== '') ? media_cover((int)$activity['id']) : '';
|
||||||
?>
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -29,8 +31,8 @@ $cover = $isEdit ? (string)($activity['cover_image'] ?? '') : '';
|
|||||||
<label>封面图</label>
|
<label>封面图</label>
|
||||||
<div class="cover-upload">
|
<div class="cover-upload">
|
||||||
<div class="cover-preview" id="cover-preview">
|
<div class="cover-preview" id="cover-preview">
|
||||||
<img id="cover-preview-img" src="<?= e($cover) ?>" alt=""
|
<img id="cover-preview-img" src="<?= e($coverPreview) ?>" alt=""
|
||||||
style="<?= $cover === '' ? 'display:none;' : '' ?>">
|
style="<?= $coverPreview === '' ? 'display:none;' : '' ?>">
|
||||||
<div class="cover-placeholder" id="cover-placeholder"
|
<div class="cover-placeholder" id="cover-placeholder"
|
||||||
style="<?= $cover === '' ? '' : 'display:none;' ?>">
|
style="<?= $cover === '' ? '' : 'display:none;' ?>">
|
||||||
<div style="font-size:32px;">🖼️</div>
|
<div style="font-size:32px;">🖼️</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<?php foreach ($activities as $a): ?>
|
<?php foreach ($activities as $a): ?>
|
||||||
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
|
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
|
||||||
<?php if (!empty($a['cover_image'])): ?>
|
<?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: ?>
|
<?php else: ?>
|
||||||
<div class="card-cover card-cover-placeholder">📸</div>
|
<div class="card-cover card-cover-placeholder">📸</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<?php if (!empty($activity['cover_image'])): ?>
|
<?php if (!empty($activity['cover_image'])): ?>
|
||||||
<div class="activity-cover">
|
<div class="activity-cover">
|
||||||
<img src="<?= e($activity['cover_image']) ?>" alt="">
|
<img src="<?= e(media_cover((int)$activity['id'])) ?>" alt="">
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
+92
-20
@@ -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>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<title><?= e($pageTitle ?? '家庭出行记录') ?></title>
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
<link rel="stylesheet" href="/assets/vendor/cropper/cropper.min.css">
|
<link rel="stylesheet" href="/assets/vendor/cropper/cropper.min.css">
|
||||||
@@ -23,24 +30,73 @@
|
|||||||
<body>
|
<body>
|
||||||
<?php if ($__isLoggedIn): ?>
|
<?php if ($__isLoggedIn): ?>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a href="<?= url('activities/index') ?>" class="brand">🏠 家庭出行记录</a>
|
<a href="<?= url('activities/index') ?>" class="brand">
|
||||||
<nav>
|
<span class="brand-mark">🧭</span>
|
||||||
<a href="<?= url('activities/index') ?>">活动列表</a>
|
<span class="brand-text">家庭出行记录</span>
|
||||||
<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>
|
</a>
|
||||||
</span>
|
|
||||||
<a href="<?= url('auth/logout') ?>" class="logout-link">退出</a>
|
<div class="topbar-right">
|
||||||
</nav>
|
<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>
|
</header>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -52,7 +108,23 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php if ($__isLoggedIn): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+522
-80
@@ -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 class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>节点附件</h1>
|
<h1>照片与附件</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>">
|
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
|
||||||
<?= e($node['activity_title']) ?>
|
|
||||||
</a>
|
|
||||||
·
|
·
|
||||||
<?= e($node['title'] ?: '无标题节点') ?>
|
<a href="<?= url('nodes/show', ['id' => $node['id']]) ?>"><?= e($node['title'] ?: '无标题节点') ?></a>
|
||||||
·
|
·
|
||||||
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
|
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
|
||||||
|
</div>
|
||||||
</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="_csrf" value="<?= e(csrf_token()) ?>">
|
||||||
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
|
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>上传附件</label>
|
<label>添加照片 / 附件</label>
|
||||||
<div class="drop-zone" id="drop-zone">
|
|
||||||
|
<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;">
|
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
|
||||||
<div class="drop-zone-inner">
|
<div class="drop-zone-inner">
|
||||||
<div style="font-size:32px;">📎</div>
|
<div class="drop-zone-icon">📷</div>
|
||||||
<p style="margin:6px 0 0;">点击或拖拽文件到这里</p>
|
<p class="drop-zone-title">点击选择,或把照片拖到这里</p>
|
||||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">
|
<p class="muted drop-zone-hint">
|
||||||
支持 JPG / PNG / GIF / WebP / PDF / DOC / XLS / MP4 等,单个不超过 20MB
|
可一次选多张。支持 JPG / PNG / GIF / WebP / HEIC、PDF、MP4 等;单张上限 <?= $maxMb ?>MB。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="form-actions">
|
<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>
|
</div>
|
||||||
</form>
|
</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)): ?>
|
<?php if (empty($attachments)): ?>
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p>暂无附件,上传第一张机票/门票/照片吧</p>
|
<p>还没有照片 —— 把登机牌、车票、门票拍下来传上去吧</p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="attachment-grid">
|
<div class="photo-grid">
|
||||||
<?php foreach ($attachments as $att):
|
<?php foreach ($attachments as $att):
|
||||||
$url = $att['file_url'];
|
$attId = (int)$att['id'];
|
||||||
$name = $att['file_name'] ?: basename($url);
|
$isImg = Attachments::isImageRow($att);
|
||||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
$stored = Attachments::storageOf($att);
|
||||||
$isImage = in_array($ext, ['jpg','jpeg','png','gif','webp','heic'], true);
|
$badge = $stored === Storage::DRIVER_OSS ? 'OSS' : '本地';
|
||||||
$filePath = dirname(__DIR__, 2) . $url;
|
$desc = Attachments::describe($att);
|
||||||
$size = is_file($filePath) ? filesize($filePath) : 0;
|
$name = $desc['name'];
|
||||||
$sizeText = $size > 0 ? ($size < 1024 * 1024
|
$mime = (string)($att['mime_type'] ?? '');
|
||||||
? round($size / 1024, 1) . ' KB'
|
$icon = $iconByMime[$mime] ?? '📎';
|
||||||
: round($size / 1024 / 1024, 2) . ' MB') : '';
|
$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">
|
<div class="photo-card">
|
||||||
<a href="<?= e($url) ?>" target="_blank" class="attachment-link">
|
<a href="<?= e($url) ?>" target="_blank" rel="noopener" title="<?= e($name) ?>">
|
||||||
<?php if ($isImage): ?>
|
<?php if ($isImg): ?>
|
||||||
<img src="<?= e($url) ?>" alt="" class="attachment-thumb">
|
<img src="<?= e($url) ?>" alt="<?= e($name) ?>" loading="lazy">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="attachment-icon">
|
<div class="attachment-icon"><?= $icon ?></div>
|
||||||
<?php
|
|
||||||
$icons = ['pdf'=>'📄','doc'=>'📝','docx'=>'📝','xls'=>'📊','xlsx'=>'📊','mp4'=>'🎬','mov'=>'🎬','zip'=>'🗜️'];
|
|
||||||
echo $icons[$ext] ?? '📎';
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
<div class="attachment-meta">
|
|
||||||
<div class="attachment-name" title="<?= e($name) ?>"><?= e($name) ?></div>
|
<span class="photo-tag photo-tag-<?= $stored === Storage::DRIVER_OSS ? 'oss' : 'local' ?>">
|
||||||
<div class="attachment-sub">
|
<?= e($badge) ?>
|
||||||
<?= e($att['uploader_name'] ?: '未知') ?>
|
</span>
|
||||||
<?php if ($sizeText): ?> · <?= e($sizeText) ?><?php endif; ?>
|
|
||||||
|
<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>
|
</div>
|
||||||
<form method="post" action="<?= url('attachments/destroy') ?>" style="display:inline;"
|
|
||||||
onsubmit="return confirm('确定删除此附件?');">
|
<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="_csrf" value="<?= e(csrf_token()) ?>">
|
||||||
<input type="hidden" name="id" value="<?= (int)$att['id'] ?>">
|
<input type="hidden" name="id" value="<?= $attId ?>">
|
||||||
<button type="submit" class="attachment-delete" title="删除">×</button>
|
<button type="submit" class="photo-del" title="删除">×</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var form = document.getElementById('upload-form');
|
||||||
var dropZone = document.getElementById('drop-zone');
|
var dropZone = document.getElementById('drop-zone');
|
||||||
var fileInput = document.getElementById('file-input');
|
var fileInput = document.getElementById('file-input');
|
||||||
var fileList = document.getElementById('file-list');
|
var stageWrap = document.getElementById('stage-wrap');
|
||||||
var btnSubmit = document.getElementById('btn-submit-upload');
|
var stage = document.getElementById('photo-stage');
|
||||||
if (!dropZone || !fileInput) return;
|
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) {
|
var csrf = <?= json_encode(csrf_token()) ?>;
|
||||||
e.preventDefault();
|
var maxBytes = <?= (int)$maxMb ?> * 1024 * 1024;
|
||||||
dropZone.classList.add('dragging');
|
var postLimit = <?= (int)$effBytes ?>;
|
||||||
});
|
var uploadUrl = <?= json_encode(url('attachments/upload')) ?>;
|
||||||
dropZone.addEventListener('dragleave', function () {
|
var nodeId = <?= (int)$node['id'] ?>;
|
||||||
dropZone.classList.remove('dragging');
|
|
||||||
});
|
/** 服务端也可以拒,但 30 个的上限在这里先挡一道 */
|
||||||
dropZone.addEventListener('drop', function (e) {
|
var MAX_FILES = 30;
|
||||||
e.preventDefault();
|
|
||||||
dropZone.classList.remove('dragging');
|
var items = []; // 暂存项
|
||||||
if (e.dataTransfer.files.length) {
|
var seq = 0;
|
||||||
fileInput.files = e.dataTransfer.files;
|
var urls = []; // 需要 revoke 的 objectURL
|
||||||
refreshList();
|
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();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', refreshList);
|
function isImageFile(f) {
|
||||||
|
if (f.type && f.type.indexOf('image/') === 0) return true;
|
||||||
|
return IMG_EXT.indexOf(extOf(f.name)) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshList() {
|
// 浏览器画布解不开的格式(典型是 HEIC),裁不了,只能原图直传
|
||||||
fileList.innerHTML = '';
|
function canvasSafe(f) {
|
||||||
var files = fileInput.files;
|
var t = String(f.type || '');
|
||||||
if (!files.length) {
|
if (t === 'image/heic' || t === 'image/heif') return false;
|
||||||
btnSubmit.style.display = 'none';
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
for (var i = 0; i < files.length; i++) {
|
var url = isImageFile(f) ? URL.createObjectURL(f) : '';
|
||||||
var f = files[i];
|
if (url) urls.push(url);
|
||||||
var size = f.size < 1024 * 1024
|
items.push({
|
||||||
? (f.size / 1024).toFixed(1) + ' KB'
|
uid: ++seq,
|
||||||
: (f.size / 1024 / 1024).toFixed(2) + ' MB';
|
file: f,
|
||||||
var row = document.createElement('div');
|
name: f.name || ('photo.' + (extOf(f.name) || 'jpg')),
|
||||||
row.className = 'file-row';
|
size: f.size,
|
||||||
row.textContent = '📎 ' + f.name + ' (' + size + ')';
|
type: f.type || '',
|
||||||
fileList.appendChild(row);
|
image: isImageFile(f),
|
||||||
|
croppable: isImageFile(f) && canvasSafe(f),
|
||||||
|
cropped: null, // 裁剪后的 dataURL
|
||||||
|
thumb: url // objectURL 或 dataURL
|
||||||
|
});
|
||||||
|
room--;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rejected.length) alert(rejected.join('\n'));
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
btnSubmit.style.display = 'inline-block';
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
stage.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
stageWrap.style.display = 'none';
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
updateSubmitLabel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stageWrap.style.display = 'block';
|
||||||
|
|
||||||
|
var total = 0;
|
||||||
|
items.forEach(function (it) {
|
||||||
|
total += it.cropped ? dataUrlBytes(it.cropped) : it.size;
|
||||||
|
});
|
||||||
|
stageCount.textContent = items.length + ' 个待上传 · 约 ' + fmtSize(total);
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
updateSubmitLabel();
|
||||||
|
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'photo-item';
|
||||||
|
|
||||||
|
var inner = '';
|
||||||
|
if (it.image && it.thumb) {
|
||||||
|
inner += '<img class="photo-thumb" src="' + esc(it.thumb) + '" alt="">';
|
||||||
|
} else {
|
||||||
|
inner += '<div class="photo-thumb photo-thumb-icon">📎</div>';
|
||||||
|
}
|
||||||
|
inner += '<div class="photo-info">'
|
||||||
|
+ '<span class="photo-name" title="' + esc(it.name) + '">' + esc(it.name) + '</span>'
|
||||||
|
+ fmtSize(it.cropped ? dataUrlBytes(it.cropped) : it.size)
|
||||||
|
+ '</div>';
|
||||||
|
if (it.cropped) {
|
||||||
|
inner += '<span class="photo-badge">已裁剪</span>';
|
||||||
|
} else if (it.image && !it.croppable) {
|
||||||
|
inner += '<span class="photo-badge photo-badge-plain">原图上传</span>';
|
||||||
|
}
|
||||||
|
inner += '<div class="photo-actions">'
|
||||||
|
+ '<button type="button" data-act="crop" data-uid="' + it.uid + '"'
|
||||||
|
+ (it.croppable ? '' : ' disabled title="该格式无法在浏览器中裁剪"') + '>裁剪</button>'
|
||||||
|
+ '<button type="button" data-act="del" data-uid="' + it.uid + '">移除</button>'
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
el.innerHTML = inner;
|
||||||
|
stage.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubmitLabel() {
|
||||||
|
if (btnSubmit.disabled) return;
|
||||||
|
btnSubmit.textContent = '上传 ' + items.length + ' 个到 <?= e($driverLabel) ?>';
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest ? ev.target.closest('button[data-act]') : null;
|
||||||
|
if (!btn) return;
|
||||||
|
var uid = parseInt(btn.getAttribute('data-uid'), 10);
|
||||||
|
if (btn.getAttribute('data-act') === 'del') removeItem(uid);
|
||||||
|
else if (btn.getAttribute('data-act') === 'crop') openCrop(uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClear.addEventListener('click', function () {
|
||||||
|
if (!items.length) return;
|
||||||
|
if (!confirm('清空暂存区里的 ' + items.length + ' 个文件?')) return;
|
||||||
|
items.slice().forEach(function (it) { removeItem(it.uid); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 选择 / 拖放 ────────────────────────────────────────────
|
||||||
|
dropZone.addEventListener('click', function () { fileInput.click(); });
|
||||||
|
dropZone.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); fileInput.click(); }
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
addFiles(fileInput.files);
|
||||||
|
fileInput.value = ''; // 允许连续选同一个文件
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function (t) {
|
||||||
|
dropZone.addEventListener(t, function (ev) {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
dropZone.classList.add('dragging');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'dragend'].forEach(function (t) {
|
||||||
|
dropZone.addEventListener(t, function (ev) {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
dropZone.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', function (ev) {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
dropZone.classList.remove('dragging');
|
||||||
|
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) {
|
||||||
|
addFiles(ev.dataTransfer.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 别让文件掉在页面其它地方被浏览器直接打开
|
||||||
|
window.addEventListener('dragover', function (ev) { ev.preventDefault(); });
|
||||||
|
window.addEventListener('drop', function (ev) { ev.preventDefault(); });
|
||||||
|
|
||||||
|
// ── 裁剪 ──────────────────────────────────────────────────
|
||||||
|
function openCrop(uid) {
|
||||||
|
var it = find(uid);
|
||||||
|
if (!it || !it.croppable) return;
|
||||||
|
if (typeof window.Cropper === 'undefined') {
|
||||||
|
alert('裁剪组件没加载出来,将按原图上传。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cropUid = uid;
|
||||||
|
cropTitle.textContent = '裁剪:' + it.name;
|
||||||
|
|
||||||
|
// 每次都换一个新的 img 节点,避免 Cropper 复用旧实例时状态残留
|
||||||
|
var fresh = document.createElement('img');
|
||||||
|
fresh.id = 'crop-image';
|
||||||
|
fresh.alt = '';
|
||||||
|
cropImg.parentNode.replaceChild(fresh, cropImg);
|
||||||
|
cropImg = fresh;
|
||||||
|
|
||||||
|
cropImg.onerror = function () {
|
||||||
|
alert('浏览器解不开这张图(可能是 HEIC 等格式),将按原图上传。');
|
||||||
|
closeCrop();
|
||||||
|
var cur = find(cropUid);
|
||||||
|
if (cur) cur.croppable = false;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
cropImg.onload = function () {
|
||||||
|
if (typeof window.Cropper === 'undefined') return;
|
||||||
|
cropper = new window.Cropper(cropImg, {
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: 'move',
|
||||||
|
autoCropArea: 1,
|
||||||
|
responsive: true,
|
||||||
|
background: false,
|
||||||
|
aspectRatio: NaN // 自由比例
|
||||||
|
});
|
||||||
|
};
|
||||||
|
cropImg.src = it.cropped ? it.cropped : (it.thumb || URL.createObjectURL(it.file));
|
||||||
|
|
||||||
|
modal.classList.add('open');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCrop() {
|
||||||
|
modal.classList.remove('open');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
|
cropUid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.addEventListener('click', function (ev) {
|
||||||
|
if (ev.target.getAttribute && ev.target.getAttribute('data-close') === '1') closeCrop();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Escape' && modal.classList.contains('open')) closeCrop();
|
||||||
|
});
|
||||||
|
document.getElementById('crop-cancel').addEventListener('click', closeCrop);
|
||||||
|
document.getElementById('crop-reset').addEventListener('click', function () {
|
||||||
|
if (cropper) cropper.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('crop-apply').addEventListener('click', function () {
|
||||||
|
if (!cropper || cropUid === null) { closeCrop(); return; }
|
||||||
|
var it = find(cropUid);
|
||||||
|
if (!it) { closeCrop(); return; }
|
||||||
|
|
||||||
|
// 限制长边,避免 base64 把请求撑爆(登机牌 1800px 已经足够看清)
|
||||||
|
var canvas = cropper.getCroppedCanvas({
|
||||||
|
maxWidth: 1800,
|
||||||
|
maxHeight: 1800,
|
||||||
|
imageSmoothingQuality: 'high',
|
||||||
|
fillColor: '#ffffff'
|
||||||
|
});
|
||||||
|
if (!canvas) { alert('裁剪失败,请重试。'); return; }
|
||||||
|
|
||||||
|
var mime = /^image\/(png|webp)$/.test(it.type) ? it.type : 'image/jpeg';
|
||||||
|
var dataUrl;
|
||||||
|
try {
|
||||||
|
dataUrl = canvas.toDataURL(mime, 0.9);
|
||||||
|
} catch (e) {
|
||||||
|
alert('导出裁剪结果失败:' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.cropped = dataUrl;
|
||||||
|
it.thumb = dataUrl;
|
||||||
|
it.name = it.name.replace(/\.[^.]+$/, '') + (mime === 'image/png' ? '.png'
|
||||||
|
: mime === 'image/webp' ? '.webp' : '.jpg');
|
||||||
|
closeCrop();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 提交 ──────────────────────────────────────────────────
|
||||||
|
form.addEventListener('submit', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!items.length || btnSubmit.disabled) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('_csrf', csrf);
|
||||||
|
fd.append('node_id', String(nodeId));
|
||||||
|
|
||||||
|
var croppedArr = [];
|
||||||
|
var total = 0;
|
||||||
|
items.forEach(function (it) {
|
||||||
|
if (it.cropped) {
|
||||||
|
croppedArr.push({ name: it.name, data: it.cropped });
|
||||||
|
total += dataUrlBytes(it.cropped);
|
||||||
|
} else {
|
||||||
|
fd.append('files[]', it.file, it.name);
|
||||||
|
total += it.size;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (croppedArr.length) fd.append('cropped', JSON.stringify(croppedArr));
|
||||||
|
|
||||||
|
if (postLimit > 0 && total > postLimit) {
|
||||||
|
alert('这次要传约 ' + fmtSize(total) + ',超过服务器单次请求上限 ' + fmtSize(postLimit)
|
||||||
|
+ '。请分几批上传。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', uploadUrl, true);
|
||||||
|
xhr.setRequestHeader('X-CSRF-Token', csrf);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
|
||||||
|
progWrap.style.display = 'block';
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
progBar.style.width = '0%';
|
||||||
|
progText.textContent = '正在上传 ' + items.length + ' 个文件…';
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (e) {
|
||||||
|
if (!e.lengthComputable) return;
|
||||||
|
var pct = Math.round(e.loaded / e.total * 100);
|
||||||
|
progBar.style.width = pct + '%';
|
||||||
|
progText.textContent = '正在上传… ' + pct + '%(' + fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ')';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = function () {
|
||||||
|
// 控制器最后是 redirect,XHR 会自动跟到目标页,所以 2xx/3xx 都算成功
|
||||||
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
|
progBar.style.width = '100%';
|
||||||
|
progText.textContent = '上传完成,正在刷新…';
|
||||||
|
window.location.href = location.pathname + location.search + '#photos';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail('上传失败(HTTP ' + xhr.status + '):' + snippet(xhr.responseText));
|
||||||
|
};
|
||||||
|
xhr.onerror = function () { fail('网络错误,上传没成功'); };
|
||||||
|
xhr.onabort = function () { fail('上传被中断'); };
|
||||||
|
|
||||||
|
function snippet(html) {
|
||||||
|
var t = String(html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return t.slice(0, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
progWrap.style.display = 'none';
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
alert(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
+125
-108
@@ -20,6 +20,20 @@ $dtLocal = static function ($value): string {
|
|||||||
};
|
};
|
||||||
$sLoc = json_decode_field($n['start_location'] ?? null);
|
$sLoc = json_decode_field($n['start_location'] ?? null);
|
||||||
$eLoc = json_decode_field($n['end_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 class="page-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -54,6 +68,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="muted">关联后可以在计划卡片上看到「已成行」。</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -87,7 +102,40 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<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">
|
<div class="form-row">
|
||||||
<label>起点名称(输入地址自动补全)</label>
|
<label>起点名称(输入地址自动补全)</label>
|
||||||
<input type="text" name="start_location_name" id="start-location-input"
|
<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>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>终点信息(可选)</legend>
|
<legend>终点(可选)</legend>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>终点名称(输入地址自动补全)</label>
|
<label>终点名称(输入地址自动补全)</label>
|
||||||
<input type="text" name="end_location_name" id="end-location-input"
|
<input type="text" name="end_location_name" id="end-location-input"
|
||||||
@@ -114,52 +162,6 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<div class="form-row">
|
||||||
<label>详细描述</label>
|
<label>详细描述</label>
|
||||||
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($n['description'] ?? '') ?></textarea>
|
<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">
|
<fieldset class="fieldset">
|
||||||
<legend>执行状态</legend>
|
<legend>执行状态</legend>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>状态</label>
|
<label for="status-select">状态</label>
|
||||||
<select name="status">
|
<select name="status" id="status-select">
|
||||||
<?php foreach ($statusOptions as $st): ?>
|
<?php foreach ($statusOptions as $st): ?>
|
||||||
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
|
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
|
||||||
<?= e($statusLabels[$st]) ?>
|
<?= e($statusLabels[$st]) ?>
|
||||||
@@ -194,20 +196,53 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(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 modeSelect = document.getElementById('travel-mode-select');
|
||||||
if (modeSelect) {
|
var tripRow = document.getElementById('field-trip-number');
|
||||||
var details = document.querySelectorAll('.transport-detail');
|
var tripLabel = document.getElementById('trip-number-label');
|
||||||
var refresh = function () {
|
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 opt = modeSelect.options[modeSelect.selectedIndex];
|
||||||
var modeType = opt ? (opt.getAttribute('data-mode-type') || '') : '';
|
var mode = opt ? (opt.getAttribute('data-mode-type') || '') : '';
|
||||||
details.forEach(function (el) {
|
|
||||||
var allow = el.getAttribute('data-fields').split(' ');
|
if (mode === '') {
|
||||||
el.style.display = allow.indexOf(modeType) !== -1 ? '' : 'none';
|
tripRow.style.display = 'none';
|
||||||
});
|
noTripRow.style.display = 'none';
|
||||||
};
|
return;
|
||||||
modeSelect.addEventListener('change', refresh);
|
}
|
||||||
refresh();
|
|
||||||
|
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) {
|
||||||
|
modeSelect.addEventListener('change', refreshTransport);
|
||||||
|
refreshTransport();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 高德地址自动补全(自定义下拉) =====
|
// ===== 高德地址自动补全(自定义下拉) =====
|
||||||
@@ -224,10 +259,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (waited >= maxWait) {
|
if (waited >= maxWait) {
|
||||||
console.error('[AMap] 等待超时。可能原因:');
|
console.error('[AMap] 等待超时。可能原因:Key 无效 / 未绑定域名 / 无法访问 webapi.amap.com');
|
||||||
console.error(' 1. Key 无效或未绑定域名白名单');
|
|
||||||
console.error(' 2. 安全密钥不匹配');
|
|
||||||
console.error(' 3. 网络无法访问 webapi.amap.com');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
waited += 200;
|
waited += 200;
|
||||||
@@ -255,37 +287,34 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
wrapper.appendChild(dropdown);
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
var autoComplete = new AMap.AutoComplete({});
|
var autoComplete = new AMap.AutoComplete({});
|
||||||
|
|
||||||
var currentIndex = -1;
|
var currentIndex = -1;
|
||||||
var currentResults = [];
|
var currentResults = [];
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDropdown(results) {
|
function renderDropdown(results) {
|
||||||
currentResults = results || [];
|
currentResults = results || [];
|
||||||
currentIndex = -1;
|
currentIndex = -1;
|
||||||
|
|
||||||
if (!currentResults.length) {
|
if (!currentResults.length) {
|
||||||
dropdown.style.display = 'none';
|
dropdown.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdown.innerHTML = currentResults.map(function (item, i) {
|
dropdown.innerHTML = currentResults.map(function (item, i) {
|
||||||
var name = item.name || '';
|
var sub = [item.district || '', item.address || ''].filter(Boolean).join(' ');
|
||||||
var district = item.district || '';
|
return '<div class="amap-dropdown-item" data-index="' + i + '">'
|
||||||
var address = item.address || '';
|
+ '<div class="amap-dropdown-name">' + escapeHtml(item.name || '') + '</div>'
|
||||||
var sub = [district, address].filter(Boolean).join(' ');
|
+ (sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '')
|
||||||
return '<div class="amap-dropdown-item" data-index="' + i + '">' +
|
+ '</div>';
|
||||||
'<div class="amap-dropdown-name">' + escapeHtml(name) + '</div>' +
|
|
||||||
(sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '') +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
dropdown.style.display = 'block';
|
dropdown.style.display = 'block';
|
||||||
|
|
||||||
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
|
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
|
||||||
el.addEventListener('mousedown', function (ev) {
|
el.addEventListener('mousedown', function (ev) {
|
||||||
ev.preventDefault(); // 防止 input 失焦
|
ev.preventDefault(); // 防止 input 失焦
|
||||||
var idx = parseInt(el.getAttribute('data-index'), 10);
|
selectItem(currentResults[parseInt(el.getAttribute('data-index'), 10)]);
|
||||||
selectItem(currentResults[idx]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,9 +322,7 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
function selectItem(item) {
|
function selectItem(item) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
input.value = item.name || '';
|
input.value = item.name || '';
|
||||||
if (addrField) {
|
if (addrField) addrField.value = item.address || item.district || '';
|
||||||
addrField.value = item.address || item.district || '';
|
|
||||||
}
|
|
||||||
if (item.location) {
|
if (item.location) {
|
||||||
if (latField) latField.value = item.location.lat;
|
if (latField) latField.value = item.location.lat;
|
||||||
if (lngField) lngField.value = item.location.lng;
|
if (lngField) lngField.value = item.location.lng;
|
||||||
@@ -303,12 +330,6 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
dropdown.style.display = 'none';
|
dropdown.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/[&<>"']/g, function (c) {
|
|
||||||
return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchTimer = null;
|
var searchTimer = null;
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
var keyword = input.value.trim();
|
var keyword = input.value.trim();
|
||||||
@@ -331,39 +352,35 @@ $eLoc = json_decode_field($n['end_location'] ?? null);
|
|||||||
input.addEventListener('blur', function () {
|
input.addEventListener('blur', function () {
|
||||||
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
|
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('focus', function () {
|
input.addEventListener('focus', function () {
|
||||||
if (currentResults.length) dropdown.style.display = 'block';
|
if (currentResults.length) dropdown.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
input.addEventListener('keydown', function (ev) {
|
||||||
input.addEventListener('keydown', function (e) {
|
|
||||||
if (dropdown.style.display === 'none') return;
|
if (dropdown.style.display === 'none') return;
|
||||||
var items = dropdown.querySelectorAll('.amap-dropdown-item');
|
var items = dropdown.querySelectorAll('.amap-dropdown-item');
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
function highlight() {
|
||||||
e.preventDefault();
|
items.forEach(function (el, i) { el.classList.toggle('active', i === currentIndex); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === 'ArrowDown') {
|
||||||
|
ev.preventDefault();
|
||||||
currentIndex = (currentIndex + 1) % items.length;
|
currentIndex = (currentIndex + 1) % items.length;
|
||||||
highlight(items);
|
highlight();
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (ev.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
ev.preventDefault();
|
||||||
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
||||||
highlight(items);
|
highlight();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (ev.key === 'Enter') {
|
||||||
if (currentIndex >= 0 && currentResults[currentIndex]) {
|
if (currentIndex >= 0 && currentResults[currentIndex]) {
|
||||||
e.preventDefault();
|
ev.preventDefault();
|
||||||
selectItem(currentResults[currentIndex]);
|
selectItem(currentResults[currentIndex]);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
} else if (ev.key === 'Escape') {
|
||||||
dropdown.style.display = 'none';
|
dropdown.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function highlight(items) {
|
|
||||||
items.forEach(function (el, i) {
|
|
||||||
el.classList.toggle('active', i === currentIndex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
|
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
|
||||||
* 一个节点一页,把挂在这个节点上的所有记录集中起来
|
* 一个节点一页,把挂在这个节点上的所有记录集中起来
|
||||||
*/
|
*/
|
||||||
|
use App\Attachments;
|
||||||
use App\Comments;
|
use App\Comments;
|
||||||
|
|
||||||
$statusLabels = [
|
$statusLabels = [
|
||||||
@@ -17,9 +18,23 @@ if (!isset($statusLabels[$st])) $st = 'planned';
|
|||||||
$sLoc = json_decode_field($node['start_location'] ?? null);
|
$sLoc = json_decode_field($node['start_location'] ?? null);
|
||||||
$eLoc = json_decode_field($node['end_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 = [];
|
$transportParts = [];
|
||||||
foreach (['trip_number', 'line_name', 'carrier', 'seat_info', 'gate'] as $field) {
|
if (!empty($node['trip_number'])) {
|
||||||
if (!empty($node[$field])) $transportParts[] = $node[$field];
|
$transportParts[] = $tripLabel . ' ' . $node['trip_number'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$durationText = '';
|
$durationText = '';
|
||||||
@@ -42,7 +57,7 @@ if (!empty($node['duration_minutes'])) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
|
<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('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>
|
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
|
||||||
<form method="post" action="<?= url('nodes/destroy') ?>" style="display:inline;"
|
<form method="post" action="<?= url('nodes/destroy') ?>" style="display:inline;"
|
||||||
onsubmit="return confirm('确定删除这个节点?它的附件、参与记录、花销和讨论都会一并删除。');">
|
onsubmit="return confirm('确定删除这个节点?它的附件、参与记录、花销和讨论都会一并删除。');">
|
||||||
@@ -77,7 +92,7 @@ if (!empty($node['duration_minutes'])) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($transportParts)): ?>
|
<?php if (!empty($transportParts)): ?>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">交通细节</span>
|
<span class="detail-label">交通</span>
|
||||||
<span class="detail-value"><?= e(implode(' · ', $transportParts)) ?></span>
|
<span class="detail-value"><?= e(implode(' · ', $transportParts)) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -117,6 +132,48 @@ if (!empty($node['duration_minutes'])) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</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__ . '/_members.php'; ?>
|
||||||
|
|
||||||
<?php require __DIR__ . '/_expenses.php'; ?>
|
<?php require __DIR__ . '/_expenses.php'; ?>
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user