节点与活动支持导出 ICS 日历(批 27)

- 新增 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 断言)。
This commit is contained in:
2026-09-29 07:58:31 +08:00
parent 7ec3ea559e
commit c8515632fb
8 changed files with 300 additions and 0 deletions
+5
View File
@@ -33,6 +33,11 @@
- 同城活动拥挤时扇形拉出排布,引线带箭头指向真实位置
- 卡片为「左图右文」方形卡片
**日历导出**
- 每个节点可导出 `.ics`(标题、起止时间、车次/航班号、出发地、备注),
导入手机/电脑的系统日历
- 整个活动一键导出为一个日历文件(已取消与已成行计划不重复导出)
**存储与基础设施**
- 存储抽象层:**本地磁盘** 或 **阿里云 OSS**,后台可切换与自检
- 媒体代理路由:照片/封面经 `media/*` 输出,**不对外暴露 OSS 地址**
+3
View File
@@ -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'],
+48
View File
@@ -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();
+75
View File
@@ -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();
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App;
/**
* RFC 5545(iCalendar)生成器 —— 导出 .ics 给用户导入系统日历。
*
* 设计要点:
* * 时间一律输出 **UTC(Z 后缀)**:所有日历客户端都认,不用内嵌 VTIMEZONE;
* 服务器时区是 Asia/Shanghai(index.php 已设定),转换交给 strtotime+gmdate。
* * UID 用 `triplog-node-{节点id}` 且**稳定不变**:用户重复导出/导入时,
* 日历端按 UID 识别为同一事件做更新,不会越导越多。
* * 行折叠按 **octet(字节)** 计数(RFC 5545 规定 75 octets),续行以空格开头;
* UTF-8 必须按字符累计字节,不能把多字节字符拦腰切断。
* * TEXT 值转义:反斜杠、分号、逗号、换行(含 \r\n)。
*/
final class Ics
{
/**
* TEXT 值转义(RFC 5545 §3.3.11)。
* 顺序必须先转反斜杠,否则会把后面刚加的转义符再转一遍。
*/
public static function escapeText(string $s): string
{
$s = str_replace('\\', '\\\\', $s);
$s = str_replace(["\r\n", "\r", "\n"], '\n', $s);
$s = str_replace([';', ','], ['\;', '\,'], $s);
return $s;
}
/**
* 折行:内容行 ≤ 75 octets,超出部分拆成以空格开头的续行。
* 按字节计数但按 UTF-8 字符切分,避免切断多字节字符。
*/
public static function foldLine(string $line): string
{
if (strlen($line) <= 75) {
return $line;
}
$out = '';
$cur = '';
$curBytes = 0;
$limit = 75;
$chars = mb_str_split($line);
foreach ($chars as $ch) {
$b = strlen($ch);
if ($curBytes + $b > $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;
}
}
+2
View File
@@ -127,6 +127,8 @@ if ($cardCategory === 'plan') {
💰 <?= (int)$n['expense_count'] ?>
</a>
<?php endif; ?>
<a href="<?= url('nodes/ics', ['id' => $n['id']]) ?>" class="node-action-link"
title="下载 .ics 导入到手机/电脑日历">📅 日历</a>
<a href="<?= url('nodes/edit', ['id' => $n['id']]) ?>" class="node-action-link">编辑</a>
<?php if ($quickStatus !== null): ?>
+2
View File
@@ -7,6 +7,8 @@
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'plan']) ?>" class="btn">+ 计划节点</a>
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'reality']) ?>" class="btn-primary">+ 执行节点</a>
<a href="<?= url('activities/edit', ['id' => $activity['id']]) ?>" class="btn">编辑活动</a>
<a href="<?= url('activities/ics', ['id' => $activity['id']]) ?>" class="btn"
title="下载 .ics 导入到手机/电脑日历">📅 导出日历</a>
<form method="post" action="<?= url('activities/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
+2
View File
@@ -56,6 +56,8 @@ if (!empty($node['duration_minutes'])) {
</p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/ics', ['id' => $node['id']]) ?>" class="btn"
title="下载 .ics 导入到手机/电脑日历">📅 导出日历</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('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>