节点与活动支持导出 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:
@@ -33,6 +33,11 @@
|
|||||||
- 同城活动拥挤时扇形拉出排布,引线带箭头指向真实位置
|
- 同城活动拥挤时扇形拉出排布,引线带箭头指向真实位置
|
||||||
- 卡片为「左图右文」方形卡片
|
- 卡片为「左图右文」方形卡片
|
||||||
|
|
||||||
|
**日历导出**
|
||||||
|
- 每个节点可导出 `.ics`(标题、起止时间、车次/航班号、出发地、备注),
|
||||||
|
导入手机/电脑的系统日历
|
||||||
|
- 整个活动一键导出为一个日历文件(已取消与已成行计划不重复导出)
|
||||||
|
|
||||||
**存储与基础设施**
|
**存储与基础设施**
|
||||||
- 存储抽象层:**本地磁盘** 或 **阿里云 OSS**,后台可切换与自检
|
- 存储抽象层:**本地磁盘** 或 **阿里云 OSS**,后台可切换与自检
|
||||||
- 媒体代理路由:照片/封面经 `media/*` 输出,**不对外暴露 OSS 地址**
|
- 媒体代理路由:照片/封面经 `media/*` 输出,**不对外暴露 OSS 地址**
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ require __DIR__ . '/src/helpers.php';
|
|||||||
require __DIR__ . '/src/Comments.php';
|
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/Ics.php';
|
||||||
require __DIR__ . '/src/Access.php';
|
require __DIR__ . '/src/Access.php';
|
||||||
require __DIR__ . '/src/Migrate.php';
|
require __DIR__ . '/src/Migrate.php';
|
||||||
require __DIR__ . '/src/Mime.php';
|
require __DIR__ . '/src/Mime.php';
|
||||||
@@ -90,6 +91,7 @@ $map = [
|
|||||||
'activities/update' => [ActivityController::class, 'update'],
|
'activities/update' => [ActivityController::class, 'update'],
|
||||||
'activities/destroy' => [ActivityController::class, 'destroy'],
|
'activities/destroy' => [ActivityController::class, 'destroy'],
|
||||||
'activities/show' => [ActivityController::class, 'show'],
|
'activities/show' => [ActivityController::class, 'show'],
|
||||||
|
'activities/ics' => [ActivityController::class, 'ics'],
|
||||||
|
|
||||||
// 节点附件
|
// 节点附件
|
||||||
'attachments/node' => [AttachmentController::class, 'node'],
|
'attachments/node' => [AttachmentController::class, 'node'],
|
||||||
@@ -111,6 +113,7 @@ $map = [
|
|||||||
'nodes/create' => [NodeController::class, 'create'],
|
'nodes/create' => [NodeController::class, 'create'],
|
||||||
'nodes/store' => [NodeController::class, 'store'],
|
'nodes/store' => [NodeController::class, 'store'],
|
||||||
'nodes/show' => [NodeController::class, 'show'],
|
'nodes/show' => [NodeController::class, 'show'],
|
||||||
|
'nodes/ics' => [NodeController::class, 'ics'],
|
||||||
'nodes/edit' => [NodeController::class, 'edit'],
|
'nodes/edit' => [NodeController::class, 'edit'],
|
||||||
'nodes/update' => [NodeController::class, 'update'],
|
'nodes/update' => [NodeController::class, 'update'],
|
||||||
'nodes/destroy' => [NodeController::class, 'destroy'],
|
'nodes/destroy' => [NodeController::class, 'destroy'],
|
||||||
|
|||||||
@@ -8,11 +8,59 @@ use App\Auth;
|
|||||||
use App\Comments;
|
use App\Comments;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Expenses;
|
use App\Expenses;
|
||||||
|
use App\Ics;
|
||||||
use App\Members;
|
use App\Members;
|
||||||
use App\Mime;
|
use App\Mime;
|
||||||
|
|
||||||
class ActivityController
|
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
|
public function index(): void
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Auth;
|
|||||||
use App\Comments;
|
use App\Comments;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Expenses;
|
use App\Expenses;
|
||||||
|
use App\Ics;
|
||||||
use App\Members;
|
use App\Members;
|
||||||
use App\Uploads;
|
use App\Uploads;
|
||||||
|
|
||||||
@@ -19,6 +20,80 @@ class NodeController
|
|||||||
/** 执行节点本身就是"已完成",额外允许 cancelled */
|
/** 执行节点本身就是"已完成",额外允许 cancelled */
|
||||||
private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned'];
|
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
|
public function create(): void
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
|||||||
+163
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,8 @@ if ($cardCategory === 'plan') {
|
|||||||
💰 <?= (int)$n['expense_count'] ?>
|
💰 <?= (int)$n['expense_count'] ?>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?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>
|
<a href="<?= url('nodes/edit', ['id' => $n['id']]) ?>" class="node-action-link">编辑</a>
|
||||||
|
|
||||||
<?php if ($quickStatus !== null): ?>
|
<?php if ($quickStatus !== null): ?>
|
||||||
|
|||||||
@@ -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' => 'plan']) ?>" class="btn">+ 计划节点</a>
|
||||||
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'reality']) ?>" class="btn-primary">+ 执行节点</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/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;"
|
<form method="post" action="<?= url('activities/destroy') ?>" style="display:inline;"
|
||||||
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
|
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
|
||||||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ if (!empty($node['duration_minutes'])) {
|
|||||||
</p>
|
</p>
|
||||||
</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/ics', ['id' => $node['id']]) ?>" class="btn"
|
||||||
|
title="下载 .ics 导入到手机/电脑日历">📅 导出日历</a>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user