From c8515632fb5e4b8946dbb19349c1ce32a1185cd2 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 29 Sep 2026 07:58:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E4=B8=8E=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=20ICS=20=E6=97=A5=E5=8E=86?= =?UTF-8?q?=EF=BC=88=E6=89=B9=2027=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/Ics.php:RFC 5545 生成器(CRLF、75 octet 折行且不切断 UTF-8 字符、TEXT 转义、时间输出 UTC Z、UID 固定为 triplog-node-{id} 使重复导入按 UID 更新而不堆重复事件)。 - 新增路由 nodes/ics、activities/ics:节点级导出单个事件 (SUMMARY=活动名·节点标题、DTSTART/DTEND、LOCATION=出发地、 DESCRIPTION=车次/航班号+备注);活动级导出整个行程(跳过已取消 节点与已生成执行记录的计划节点,口径与详情页展示一致)。 - 入口:活动详情页「导出日历」、节点详情页「导出日历」、 节点卡片「日历」链接。 - 修复隐性事故:index.php 漏 require src/Ics.php(无 autoloader 的 项目里新增类必须同步入口),部署哨兵新增「src/ 每个类文件都被 index.php require」逐文件对账。 - 验证:php -l 61 文件全过;渲染检查 386 项全过;Ics 单测 42 项 全过(转义/折行/UTC/拼装);服务器 e2e 391 项全过(含 11c 节 17 项 ICS 断言)。 --- README.md | 5 + index.php | 3 + src/Controllers/ActivityController.php | 48 ++++++++ src/Controllers/NodeController.php | 75 ++++++++++++ src/Ics.php | 163 +++++++++++++++++++++++++ templates/activities/_node_card.php | 2 + templates/activities/show.php | 2 + templates/nodes/show.php | 2 + 8 files changed, 300 insertions(+) create mode 100644 src/Ics.php diff --git a/README.md b/README.md index 01f6729..8d48b11 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ - 同城活动拥挤时扇形拉出排布,引线带箭头指向真实位置 - 卡片为「左图右文」方形卡片 +**日历导出** +- 每个节点可导出 `.ics`(标题、起止时间、车次/航班号、出发地、备注), + 导入手机/电脑的系统日历 +- 整个活动一键导出为一个日历文件(已取消与已成行计划不重复导出) + **存储与基础设施** - 存储抽象层:**本地磁盘** 或 **阿里云 OSS**,后台可切换与自检 - 媒体代理路由:照片/封面经 `media/*` 输出,**不对外暴露 OSS 地址** diff --git a/index.php b/index.php index 9599307..4fa0c7d 100644 --- a/index.php +++ b/index.php @@ -24,6 +24,7 @@ require __DIR__ . '/src/helpers.php'; require __DIR__ . '/src/Comments.php'; require __DIR__ . '/src/Members.php'; require __DIR__ . '/src/Expenses.php'; +require __DIR__ . '/src/Ics.php'; require __DIR__ . '/src/Access.php'; require __DIR__ . '/src/Migrate.php'; require __DIR__ . '/src/Mime.php'; @@ -90,6 +91,7 @@ $map = [ 'activities/update' => [ActivityController::class, 'update'], 'activities/destroy' => [ActivityController::class, 'destroy'], 'activities/show' => [ActivityController::class, 'show'], + 'activities/ics' => [ActivityController::class, 'ics'], // 节点附件 'attachments/node' => [AttachmentController::class, 'node'], @@ -111,6 +113,7 @@ $map = [ 'nodes/create' => [NodeController::class, 'create'], 'nodes/store' => [NodeController::class, 'store'], 'nodes/show' => [NodeController::class, 'show'], + 'nodes/ics' => [NodeController::class, 'ics'], 'nodes/edit' => [NodeController::class, 'edit'], 'nodes/update' => [NodeController::class, 'update'], 'nodes/destroy' => [NodeController::class, 'destroy'], diff --git a/src/Controllers/ActivityController.php b/src/Controllers/ActivityController.php index d2772f7..472daad 100644 --- a/src/Controllers/ActivityController.php +++ b/src/Controllers/ActivityController.php @@ -8,11 +8,59 @@ use App\Auth; use App\Comments; use App\Database; use App\Expenses; +use App\Ics; use App\Members; use App\Mime; class ActivityController { + /** + * 活动级导出:整个活动 → 一个 .ics(多个 VEVENT),导入后就是这次旅行的完整日历。 + * + * 口径与详情页展示一致(批 27): + * * 已取消的节点不导出(日历是「要去/去过」的安排,取消的没有意义); + * * 计划节点若已生成执行记录,只导执行版(同一个行程导两遍会重复提醒); + * * 事件 UID 与节点级导出同源(triplog-node-{id}),两个入口重复导入不堆事件。 + */ + public function ics(): void + { + $db = Database::get(); + $id = (int)($_GET['id'] ?? 0); + $activity = $this->fetchOwnActivity($id); + if (!$activity) { + http_response_code(404); + exit('活动不存在'); + } + + $stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, tm.mode_type AS travel_mode_type + FROM trip_nodes tn + LEFT JOIN node_types nt ON nt.id = tn.node_type_id + LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_id + WHERE tn.activity_id = ? + ORDER BY tn.start_time ASC, tn.id ASC'); + $stmt->execute([$id]); + $nodes = $stmt->fetchAll(); + + // 已生成执行记录的计划节点 id 集合(这些计划版跳过,导执行版) + $executed = []; + foreach ($nodes as $n) { + if (!empty($n['original_plan_node_id'])) { + $executed[(int)$n['original_plan_node_id']] = true; + } + } + + $blocks = []; + foreach ($nodes as $n) { + if (($n['status'] ?? '') === 'cancelled') continue; + if (isset($executed[(int)$n['id']])) continue; + $n['activity_title'] = (string)$activity['title']; + $blocks[] = Ics::event(NodeController::buildEventFields($n)); + } + + Ics::emit('TripLog-' . (string)$activity['title'], + Ics::calendar($blocks, (string)$activity['title'])); + } + public function index(): void { $db = Database::get(); diff --git a/src/Controllers/NodeController.php b/src/Controllers/NodeController.php index ad6754c..dcb2780 100644 --- a/src/Controllers/NodeController.php +++ b/src/Controllers/NodeController.php @@ -9,6 +9,7 @@ use App\Auth; use App\Comments; use App\Database; use App\Expenses; +use App\Ics; use App\Members; use App\Uploads; @@ -19,6 +20,80 @@ class NodeController /** 执行节点本身就是"已完成",额外允许 cancelled */ private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned']; + /** + * 节点行 → ICS VEVENT 字段(批 27:节点级 / 活动级导出共用一份映射)。 + * + * $node 需含:id, title, start_time, end_time, start_location, description, + * trip_number, status;可选:node_type_name, travel_mode_type, activity_title。 + * + * 内容对应关系(用户需求):SUMMARY=标题(活动名 · 节点标题)、DTSTART/DTEND=起止时间、 + * LOCATION=出发地、DESCRIPTION=车次/航班号 + 备注;UID 固定为 triplog-node-{id}, + * 重复导入日历时按 UID 更新而不是堆重复事件。 + */ + public static function buildEventFields(array $node): array + { + $title = trim((string)($node['title'] ?? '')); + if ($title === '') { + $title = trim((string)($node['node_type_name'] ?? '')) ?: '节点'; + } + $actTitle = trim((string)($node['activity_title'] ?? '')); + $summary = $actTitle !== '' ? $actTitle . ' · ' . $title : $title; + + $sLoc = json_decode_field($node['start_location'] ?? null); + $location = trim((string)($sLoc['name'] ?? '')); + if ($location === '') { + $location = trim((string)($sLoc['address'] ?? '')); + } + + $descParts = []; + $trip = trim((string)($node['trip_number'] ?? '')); + if ($trip !== '') { + // 与 nodes/show.php 的展示口径一致:按出行方式选「航班号/车次/班次…」 + $labels = ['plane' => '航班号', 'train' => '车次', 'bus' => '班次', + 'subway' => '线路', 'ship' => '航次', 'taxi' => '车牌']; + $label = $labels[(string)($node['travel_mode_type'] ?? '')] ?? '班次'; + $descParts[] = $label . ':' . $trip; + } + $note = trim((string)($node['description'] ?? '')); + if ($note !== '') { + $descParts[] = $note; + } + if (($node['status'] ?? '') === 'cancelled') { + array_unshift($descParts, '⚠ 该节点已取消'); + } + + return [ + 'uid' => 'triplog-node-' . (int)$node['id'], + 'summary' => $summary, + 'start' => (string)($node['start_time'] ?? ''), + 'end' => (string)($node['end_time'] ?? ''), + 'location' => $location, + 'description' => implode("\n", $descParts), + ]; + } + + /** 节点级导出:单个节点 → 一个 VEVENT 的 .ics 下载 */ + public function ics(): void + { + $db = Database::get(); + $node = Access::node((int)($_GET['id'] ?? 0)); // 含家庭校验与 activity_title + + // 补齐 buildEventFields 需要的展示字段(类型名 / 出行方式类型) + $stmt = $db->prepare('SELECT name FROM node_types WHERE id = ?'); + $stmt->execute([(int)$node['node_type_id']]); + $node['node_type_name'] = (string)($stmt->fetchColumn() ?: ''); + $node['travel_mode_type'] = ''; + if (!empty($node['travel_mode_id'])) { + $stmt = $db->prepare('SELECT mode_type FROM travel_modes WHERE id = ?'); + $stmt->execute([(int)$node['travel_mode_id']]); + $node['travel_mode_type'] = (string)($stmt->fetchColumn() ?: ''); + } + + $fields = self::buildEventFields($node); + $actTitle = trim((string)($node['activity_title'] ?? '')); + Ics::emit('TripLog-' . $fields['summary'], Ics::calendar([Ics::event($fields)], $actTitle)); + } + public function create(): void { $db = Database::get(); diff --git a/src/Ics.php b/src/Ics.php new file mode 100644 index 0000000..4b5d57c --- /dev/null +++ b/src/Ics.php @@ -0,0 +1,163 @@ + $limit) { + $out .= ($out === '' ? '' : "\r\n ") . $cur; + $cur = $ch; + $curBytes = $b; + $limit = 74; // 续行有一个前导空格,内容只剩 74 + } else { + $cur .= $ch; + $curBytes += $b; + } + } + if ($cur !== '') { + $out .= ($out === '' ? '' : "\r\n ") . $cur; + } + return $out; + } + + /** MySQL datetime(服务器时区)→ UTC 基本格式(20261001T000000Z) */ + public static function utc(?string $mysqlDatetime): string + { + $ts = strtotime((string)$mysqlDatetime); + if ($ts === false) { + return ''; + } + return gmdate('Ymd\THis\Z', $ts); + } + + /** DTSTAMP 用(当前时刻 UTC) */ + public static function nowUtc(): string + { + return gmdate('Ymd\THis\Z'); + } + + /** + * 生成一个 VEVENT 块(含首尾行,行间 CRLF,结尾 CRLF)。 + * + * $e 字段: + * uid (必填) summary (必填) start / end (MySQL datetime,必填) + * location (可空,空则不输出 LOCATION 行) + * description (可空) + */ + public static function event(array $e): string + { + $lines = [ + 'BEGIN:VEVENT', + 'UID:' . self::escapeText((string)$e['uid']), + 'DTSTAMP:' . self::nowUtc(), + 'DTSTART:' . self::utc((string)$e['start']), + 'DTEND:' . self::utc((string)$e['end']), + 'SUMMARY:' . self::escapeText((string)$e['summary']), + ]; + $loc = trim((string)($e['location'] ?? '')); + if ($loc !== '') { + $lines[] = 'LOCATION:' . self::escapeText($loc); + } + $desc = trim((string)($e['description'] ?? '')); + if ($desc !== '') { + $lines[] = 'DESCRIPTION:' . self::escapeText($desc); + } + $lines[] = 'END:VEVENT'; + return self::render($lines); + } + + /** + * 包装成 VCALENDAR。 + * $events:若干个 event() 的返回串(或完整 VEVENT 文本),直接拼接。 + * $name:日历名(X-WR-CALNAME,客户端导入时的默认日历名;可空)。 + */ + public static function calendar(array $eventBlocks, string $name = ''): string + { + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TripLog//Family Trip Diary//CN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + ]; + if (trim($name) !== '') { + $lines[] = 'X-WR-CALNAME:' . self::escapeText($name); + } + return self::render($lines) . implode('', $eventBlocks) + . 'END:VCALENDAR' . "\r\n"; + } + + /** 行数组 → 折行 + CRLF 连接 */ + private static function render(array $lines): string + { + $out = ''; + foreach ($lines as $line) { + $out .= self::foldLine($line) . "\r\n"; + } + return $out; + } + + /** + * 以「日历文件下载」输出并结束请求。 + * filename 走 RFC 5987(filename*)支持中文;ASCII fallback 兜底老客户端。 + */ + public static function emit(string $filename, string $body): void + { + // 文件名只去掉文件系统不允许的字符,中文保留(filename* 负责编码) + $safe = trim(preg_replace('/[\\\\\/:*?"<>|\r\n\0-\x1F]/', '', $filename) ?? ''); + if ($safe === '') { + $safe = 'triplog'; + } + if (!str_ends_with(strtolower($safe), '.ics')) { + $safe .= '.ics'; + } + header('Content-Type: text/calendar; charset=utf-8'); + header('Content-Disposition: attachment; filename="triplog.ics"; filename*=UTF-8\'\'' + . rawurlencode($safe)); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store'); + echo $body; + exit; + } +} diff --git a/templates/activities/_node_card.php b/templates/activities/_node_card.php index 8b32c29..7a775c1 100644 --- a/templates/activities/_node_card.php +++ b/templates/activities/_node_card.php @@ -127,6 +127,8 @@ if ($cardCategory === 'plan') { 💰 + 📅 日历 编辑 diff --git a/templates/activities/show.php b/templates/activities/show.php index 6897fba..17fd172 100644 --- a/templates/activities/show.php +++ b/templates/activities/show.php @@ -7,6 +7,8 @@ + 计划节点 + 执行节点 编辑活动 + 📅 导出日历
diff --git a/templates/nodes/show.php b/templates/nodes/show.php index ff115d4..e04c5eb 100644 --- a/templates/nodes/show.php +++ b/templates/nodes/show.php @@ -56,6 +56,8 @@ if (!empty($node['duration_minutes'])) {