v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)

用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
2026-10-05 13:26:49 +08:00
parent 7138314dc7
commit 2e51d3f5e4
16 changed files with 2265 additions and 456 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* 行程花销(v24 第 16 批 用户第 2 / 7 条)。
*
* 用户第 2 条要旅程页显示"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
* TripLog 的花销就是**挂在节点上**的一笔一笔。本次确认:「行程级花销记录」。
*
* 设计取舍:
* · 一行 = 一段行程里的一笔花销(谁付的 / 多少 / 什么类别 / 说明);
* · **旅程的总花销 = 它下面所有行程的花销之和** —— 读时算,不存冗余字段。
* 存了就要在每次增删改花销后同步维护,"漏同步"是迟早的事(这个项目里
* 同一类错误已经犯过好几次:参与人、时间、城市都是这么改成派生值的)。
* · **不做分摊**(TripLog 有 expense_splits):家庭出行记"一共花了多少"就够,
* 谁欠谁那是财务模块的事,塞进旅行里会让记账变成负担。
* · 金额存**分**(与财务 ledger_entries 同一口径):浮点数做加法迟早差一分钱。
*/
declare(strict_types=1);
namespace App;
final class Expense
{
/** 花销类别(家庭出游够用;TripLog 的类别也差不多是这几种) */
public const CATS = [
'meal' => '餐饮',
'ticket' => '门票',
'traffic' => '交通',
'stay' => '住宿',
'shop' => '购物',
'other' => '其他',
];
/** 一段行程的花销(按时间正序) */
public static function ofNode(int $nodeId): array
{
$st = Database::get()->prepare(
'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color
FROM trip_expenses e
LEFT JOIN persons p ON p.id = e.person_id
WHERE e.node_id = ? ORDER BY e.created_at, e.id'
);
$st->execute([$nodeId]);
return $st->fetchAll();
}
/**
* 一段行程的花销合计(单位:分)。
*
* ⚠️ COALESCE 不能省:没有任何花销时 SUM 返回 NULL,直接拿去 number_format
* 会在 PHP 8 上报"传 null 给 float 参数"的弃用告警(页面上会冒一行 Warning)。
*/
public static function sumOfNode(int $nodeId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(amount_cents), 0) FROM trip_expenses WHERE node_id = ?'
);
$st->execute([$nodeId]);
return (int)$st->fetchColumn();
}
/** 一段旅程(活动)的总花销:它下面**所有**行程之和(用户第 2 条) */
public static function sumOfEvent(int $eventId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(e.amount_cents), 0)
FROM trip_expenses e
JOIN trip_nodes n ON n.id = e.node_id
WHERE n.event_id = ?'
);
$st->execute([$eventId]);
return (int)$st->fetchColumn();
}
/**
* 给一批行程批量补"这一段花了多少"(列表/详情用;N+1 在几十段时很明显)。
*
* @param array $rows 行程数组(会被就地补上 `spent_cents`)
*/
public static function attachSums(array &$rows): void
{
$ids = [];
foreach ($rows as $r) {
$id = (int)($r['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$sum = [];
foreach ($ids as $id) {
$sum[$id] = 0;
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT node_id, COALESCE(SUM(amount_cents),0) AS s
FROM trip_expenses WHERE node_id IN ($in) GROUP BY node_id"
);
$st->execute($ids);
foreach ($st->fetchAll() as $r) {
$sum[(int)$r['node_id']] = (int)$r['s'];
}
}
foreach ($rows as &$r) {
$r['spent_cents'] = $sum[(int)$r['id']] ?? 0;
}
unset($r);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function add(int $familyId, int $nodeId, array $d): array
{
$cents = self::parseAmount((string)($d['amount'] ?? ''));
if ($cents === null) {
return ['ok' => false, 'error' => '金额填得不对(例如 128 或 128.50)'];
}
$title = mb_substr(trim((string)($d['title'] ?? '')), 0, 200, 'UTF-8');
$cat = isset(self::CATS[(string)($d['category'] ?? '')]) ? (string)$d['category'] : 'other';
$pid = (int)($d['person_id'] ?? 0);
if ($pid > 0) {
// 只认本家的人(表单可以改,不能凭它把别人家的人在页面上引出来)
$st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?');
$st->execute([$pid, $familyId]);
if ($st->fetchColumn() === false) {
$pid = 0;
}
}
$db = Database::get();
$db->prepare(
'INSERT INTO trip_expenses (family_id, node_id, person_id, amount_cents, category, title)
VALUES (?,?,?,?,?,?)'
)->execute([$familyId, $nodeId, $pid > 0 ? $pid : null, $cents, $cat,
$title !== '' ? $title : null]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/** 删一笔(校验它确实属于这个家的某段行程) */
public static function destroy(int $familyId, int $id): bool
{
$st = Database::get()->prepare('SELECT id FROM trip_expenses WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
if ($st->fetchColumn() === false) {
return false;
}
Database::get()->prepare('DELETE FROM trip_expenses WHERE id = ?')->execute([$id]);
return true;
}
// ---------------- 展示 ----------------
/** 分 → 人话(¥1,280 / ¥12.50;金额为零显示 ¥0) */
public static function money(int $cents): string
{
$neg = $cents < 0;
$v = abs($cents) / 100;
// 整数金额不带小数尾巴(¥1280 比 ¥1,280.00 好读);有角分才显示两位
$s = (fmod($v, 1.0) === 0.0) ? number_format($v) : number_format($v, 2);
return ($neg ? '-¥' : '¥') . $s;
}
/**
* 表单里那个金额字符串 → 分。
*
* ⚠️ 不用 `(float)` 直接乘 100:`128.55 * 100` 在浮点下是 12854.999…,
* `(int)` 一截就变成 12854(差一分钱)。这里走**字符串**拆小数点,
* 整数部分与小数部分分开算,没有浮点参与。
*/
public static function parseAmount(string $raw): ?int
{
$raw = trim(str_replace([',', ',', '¥', '¥', ' '], '', $raw));
if ($raw === '') {
return null;
}
if (!preg_match('/^(-?)(\d+)(?:\.(\d{1,2}))?$/', $raw, $m)) {
return null;
}
$yuan = (int)$m[2];
$cent = isset($m[3]) ? (int)str_pad($m[3], 2, '0') : 0;
$total = $yuan * 100 + $cent;
return $m[1] === '-' ? -$total : $total;
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
/**
* 行程节点类型(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」
*
* 为什么从"写死的常量"改成"可自定义的表":
* 用户第 7 条要求照 TripLog 的添加节点功能做,TripLog 的节点类型本来就是
* 一张用户可增删的表(系统内置几种 + 自己加)。家庭出游的场景差异很大:
* 有人要「赶海」有人要「探亲」,写死的五种永远差一点。
*
* ⚠️ 与 `Trip::NODE_TYPES` 常量的关系:
* 常量是**初始值与兜底**(表还没建、或某个 type 值已从表里删掉但老数据还在用它),
* 表是"用户改过的真相"。读取一律走 `all()`,它保证表里至少有内置那几种。
* 老数据里的 `trip_nodes.node_type` 存的是 **key**('play'/'meal'…),
* 用户自定义的类型 key 就用 `c<id>` 这种形态,与内置的英文 key 不会撞。
*/
declare(strict_types=1);
namespace App;
final class NodeType
{
/** 内置类型:key => [名字, 图标](与 Trip::NODE_TYPES 保持一致,是初始值) */
public const BUILTIN = [
'play' => ['游玩', '🎡'],
'meal' => ['用餐', '🍜'],
'move' => ['赶路', '🚗'],
'stay' => ['住宿', '🏨'],
'other' => ['其他', '📌'],
];
/**
* 一个家的全部类型(表里没有就先把内置的预置进去)。
*
* ⚠️ 预置放在**读**里而不是只放在安装/升级里:升级步骤是容错的
* (`tryStep`),万一那一步在某个老库上失败了,这里还能自愈 ——
* 否则"类型"是新建行程的必填项,缺了它整个"添加行程"都用不了。
*/
public static function all(int $familyId): array
{
self::seedDefaults($familyId);
$st = Database::get()->prepare(
'SELECT id, name, icon, is_system, position FROM node_types
WHERE family_id = ? ORDER BY position, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 把内置类型补进表里(幂等:按 (family_id, name) 唯一键查过再插)。
*
* @return int 补了几条
*/
public static function seedDefaults(int $familyId): int
{
$db = Database::get();
// 表可能还不存在(老库升级跑到这一步之前 / 极少数失败情形)→ 直接返回,
// 别让"读列表"这个动作本身抛异常把页面炸掉。
try {
$n = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
$has = $n;
} catch (\Throwable $e) {
return 0;
}
if ($has >= count(self::BUILTIN)) {
return 0; // 已经有内置那几条了,不必逐个查(热点路径)
}
$hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
// 每次读都补回去会让人永远删不掉系统内置的那几种。
if ($hasAny > 0) {
return 0;
}
$st = $db->prepare(
'INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,1,?)'
);
$pos = 10;
$n = 0;
foreach (self::BUILTIN as $k => $v) {
$st->execute([$familyId, $v[0], $v[1], $pos]);
$pos += 10;
$n++;
}
return $n;
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 同名判重(返回已存在的 id,没有则 0) */
public static function findByName(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1'
);
$st->execute([$familyId, trim($name)]);
return (int)($st->fetchColumn() ?: 0);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function save(int $familyId, array $d, ?int $id = null): array
{
$name = mb_substr(trim((string)($d['name'] ?? '')), 0, 30, 'UTF-8');
if ($name === '') {
return ['ok' => false, 'error' => '类型名字不能为空'];
}
$icon = mb_substr(trim((string)($d['icon'] ?? '')), 0, 20, 'UTF-8');
$db = Database::get();
$dup = self::findByName($familyId, $name);
if ($dup > 0 && $dup !== $id) {
return ['ok' => false, 'error' => '已经有一个叫「' . $name . '」的类型了'];
}
if ($id !== null && $id > 0) {
$old = self::find($familyId, $id);
if (!$old) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
$oldName = (string)$old['name'];
$db->prepare('UPDATE node_types SET name=?, icon=? WHERE id=? AND family_id=?')
->execute([$name, $icon !== '' ? $icon : null, $id, $familyId]);
// ⚠️ 改名必须**级联**到已经在用它的行程:`trip_nodes.node_type` 存的是
// **名字**(不是 id)。只改类型表的话,所有老行程都会变成
// "一个类型表里不存在的值" → 图标与名称显示双双落回"其他",
// 用户看到的是"我一改名,历史行程的类型全没了"。
if ($oldName !== $name) {
$db->prepare('UPDATE trip_nodes SET node_type = ? WHERE family_id = ? AND node_type = ?')
->execute([$name, $familyId, $oldName]);
}
return ['ok' => true, 'id' => $id];
}
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0)+10 FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn());
$db->prepare('INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,0,?)')
->execute([$familyId, $name, $icon !== '' ? $icon : null, $pos]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 删一个类型。
*
* ⚠️ 内置的**不许删**:它们被 `node_type` 的兜底逻辑引用(表空时会重新预置),
* 删了会立刻又冒出来,看起来像"删除没生效"。要停用请改名或忽略。
* ⚠️ 还在被行程使用的也不许删:行程里存的是**名字**(见 usedCount),
* 删了类型定义,那些行程的显示就会落回"其他"—— 数据没丢,但用户会以为丢了。
* 所以这里给出"还有 N 段行程在用"的明确理由,而不是静默失败。
*/
public static function destroy(int $familyId, int $id): array
{
$t = self::find($familyId, $id);
if (!$t) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
if ((int)$t['is_system'] === 1) {
return ['ok' => false, 'error' => '内置类型不能删除(可以改名)'];
}
$n = self::usedCount($familyId, (string)$t['name']);
if ($n > 0) {
return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这个类型,先改掉它们再删'];
}
Database::get()->prepare('DELETE FROM node_types WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
return ['ok' => true];
}
/** 有多少段行程在用这个类型名 */
public static function usedCount(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM trip_nodes WHERE family_id = ? AND node_type = ?'
);
$st->execute([$familyId, $name]);
return (int)$st->fetchColumn();
}
/**
* 类型名 → 图标。行程里存的是名字,图标随类型定义走。
* 认不出(类型被删/老数据是英文 key)时返回内置兜底。
*/
public static function iconOf(int $familyId, string $name): string
{
static $cache = [];
if (!isset($cache[$familyId])) {
$map = [];
foreach (self::all($familyId) as $t) {
$map[(string)$t['name']] = (string)($t['icon'] ?? '');
}
$cache[$familyId] = $map;
}
$icon = $cache[$familyId][$name] ?? '';
if ($icon !== '') {
return $icon;
}
// 老数据用的是英文 key(play/move/…)→ 回落到内置那张表
return self::BUILTIN[$name][1] ?? '📌';
}
}
+126 -1
View File
@@ -80,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '20';
public const VERSION = '21';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -730,6 +730,92 @@ CREATE TABLE IF NOT EXISTS `book_notes` (
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)'
SQL,
// ---- 车辆库(版本 21,用户 2026-10-05 第 16 批第 8 条)----
// 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
// 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复
// 添加那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
// 比如租的车,借别人的车。」
//
// ⚠️ 车辆有**两个来源**,这里刻意**不合并**:
// ① **财务里的车子**(ledger_entries.kind='car' 的账目)—— 那是"资产",
// 记的是这台车值多少钱,不是"出行时可挑的一辆车"(没有车牌、没有车型名)。
// 这里**读它**、在选车时列出来(只读引用),但绝不复制一份到本表 ——
// 复制就变成同一台车两处维护,改了财务这边不同步。
// ② **本表** —— "出行时会用到的车",重点是车牌 + 车型名 + 是租的还是借的。
// 自家车在这里也**可以**录一份(干净、可挑),但不强制:
// 不录也能出行(只是选车时没得挑而已)。
// 所以"租的车 / 借别人的车"是本表的常客,`vehicle_type` 才需要 rental/borrowed
// 这两种;自家车与财务的对齐靠**车型名 + 车牌**人工对上,不做外键硬绑
// (账目随时可能被删/改名,硬绑会让车辆跟着消失)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`plate` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '车牌号(借的车/租的车可能没记全,允许空)',
`name` VARCHAR(100) NOT NULL COMMENT '车型名,如「大众朗逸」「丰田卡罗拉」',
`vtype` ENUM('own','rental','borrowed') NOT NULL DEFAULT 'own'
COMMENT 'own=自家的 / rental=租的 / borrowed=借别人的',
`vendor` VARCHAR(100) DEFAULT NULL COMMENT '租车公司(vtype=rental 时)或车主(borrowed 时)',
`order_no` VARCHAR(50) DEFAULT NULL COMMENT '租车订单号',
`ledger_id` INT UNSIGNED DEFAULT NULL COMMENT '关联财务里那条 kind=car 的账目(软关联,可空;账目删了这里只是记不住,不影响车辆)',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '默认车(新建行程选车时默认选中它)',
`note` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_veh_family` (`family_id`, `vtype`),
CONSTRAINT `fk_veh_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行用的车辆(自家 / 租的 / 借的);财务里的车子是资产,不复制进来'
SQL,
// ---- 行程节点类型(版本 21,第 16 批第 8 条 + TripLog 同款)----
// 用户第 7 条要求「照 TripLog 的添加节点功能」,TripLog 的节点类型
// 就是一张**可自定义**的表(游玩/用餐/赶路/休息/突发…),不是写死的枚举。
// ⚠️ 与旧代码里那个 `Trip::NODE_TYPES` 常量并存一段:常量当**兜底**
// (老库/新装库都要有初始值),表是"用户改过的真相"。
// 读取一律 `NodeType::all()`(表空时自动回落到常量并预置进表)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `node_types` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(30) NOT NULL COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 休息 / 突发…',
`icon` VARCHAR(20) DEFAULT NULL COMMENT '一个表情符号',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '内置类型(预置的那几种),不允许删',
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_nt_family_name` (`family_id`, `name`),
CONSTRAINT `fk_nt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点类型库(可自定义)'
SQL,
// ---- 行程花销(版本 21,第 16 批第 2/7 条)----
// 用户第 2 条要"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
// 而 TripLog 的花销是**挂在节点上**的一笔一笔(谁付的 / 多少 / 什么类别)。
// ⚠️ 用户明确"行程级花销记录"(本次确认),所以:
// · 一行 = 一段行程里的一笔花销;
// · 旅程的总花销 = 它下面所有行程的花销之和(读时算,不存冗余)。
// ⚠️ 不做 `expense_splits`(分摊):家庭出行记"一共花了多少"就够,
// 谁欠谁那是财务模块的事,硬塞进旅行里会让记账变成负担。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_expenses` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`node_id` INT UNSIGNED NOT NULL COMMENT '属于哪一段行程',
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁付的钱(可空)',
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额,单位**分**(与财务同一口径,避免浮点误差)',
`currency` VARCHAR(10) NOT NULL DEFAULT 'CNY',
`category` VARCHAR(30) DEFAULT NULL COMMENT '餐饮 / 门票 / 交通 / 购物 / 其他',
`title` VARCHAR(200) DEFAULT NULL COMMENT '消费说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_te_node` (`node_id`),
KEY `idx_te_family` (`family_id`),
CONSTRAINT `fk_te_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)'
SQL,
];
}
@@ -981,6 +1067,45 @@ SQL,
}
});
// ---- v24 第 16 批:行程对齐 TripLog(Schema v21)----
// 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
// TripLog 的节点比 FamilyBook 现有的"行程"多这些:
// 出行方式(走路/开车/高铁/飞机…)+ 关联车辆、车次/航班号、座位/车厢、
// 登机口/站台、起终点位置(带坐标)、线路名/承运公司。
// 这些一律**补列**到 trip_nodes 上(不是另建卫星表):
// ① 它们与行程是 1:1(一段行程一种出行方式),卫星表只是多一次 JOIN;
// ② 旅行模块里"这一段怎么到的"是**必看信息**,不是可有可无的附加物。
self::addColumn($db, 'trip_nodes', 'travel_mode',
"ADD COLUMN `travel_mode` VARCHAR(20) DEFAULT NULL COMMENT '出行方式:walk/drive/bus/subway/train/plane/ship/taxi/bike/other' AFTER `place`");
self::addColumn($db, 'trip_nodes', 'vehicle_id',
"ADD COLUMN `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT '关联车辆(仅自驾:自家/租的/借的)' AFTER `travel_mode`");
self::addColumn($db, 'trip_nodes', 'line_name',
"ADD COLUMN `line_name` VARCHAR(100) DEFAULT NULL COMMENT '线路名,如「京沪高铁」「地铁 3 号线」' AFTER `vehicle_id`");
self::addColumn($db, 'trip_nodes', 'carrier',
"ADD COLUMN `carrier` VARCHAR(100) DEFAULT NULL COMMENT '承运方,如「中国铁路」「滴滴」' AFTER `line_name`");
self::addColumn($db, 'trip_nodes', 'trip_no',
"ADD COLUMN `trip_no` VARCHAR(50) DEFAULT NULL COMMENT '车次 / 航班号 / 线路号' AFTER `carrier`");
self::addColumn($db, 'trip_nodes', 'seat_info',
"ADD COLUMN `seat_info` VARCHAR(100) DEFAULT NULL COMMENT '座位号 / 车厢' AFTER `trip_no`");
self::addColumn($db, 'trip_nodes', 'gate',
"ADD COLUMN `gate` VARCHAR(20) DEFAULT NULL COMMENT '登机口 / 站台' AFTER `seat_info`");
self::addColumn($db, 'trip_nodes', 'from_place',
"ADD COLUMN `from_place` VARCHAR(200) DEFAULT NULL COMMENT '出发地(赶路段的起点)' AFTER `gate`");
self::addColumn($db, 'trip_nodes', 'to_place',
"ADD COLUMN `to_place` VARCHAR(200) DEFAULT NULL COMMENT '到达地(赶路段的终点)' AFTER `from_place`");
// 车型/车次这类字段只在"赶路"时有意义,但**不做条件列**(列固定、界面按类型显隐),
// 免得"换个出行方式就把填过的车次弄丢"。
self::addColumn($db, 'trip_node_members', 'role_tag',
"ADD COLUMN `role_tag` VARCHAR(30) DEFAULT NULL COMMENT '角色标签,如「司机」「摄影担当」' AFTER `person_id`");
// 车辆库三张表在 statements() 里(IF NOT EXISTS 幂等),老库由那条统一建。
// 但**预置节点类型**要显式跑一次:老库建完表是空的,界面上会一个类型都没有
// ("类型"是新建行程的必填项 → 直接卡死,人以为功能坏了)。
self::tryStep('预置行程节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedDefaults((int)$fam['id']);
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
+219 -17
View File
@@ -41,6 +41,32 @@ final class Trip
'cancelled' => '已取消',
];
/**
* 出行方式(v24 第 16 批 第 7 条,照 TripLog 的 travel_modes)。
*
* ⚠️ 这里存的是**枚举 key**(不是 TripLog 那种另一张可配置的表):
* 出行方式是**固定的物理事实**(走路/开车/高铁/飞机…),
* 不像"节点类型"那样因人而异 —— 没人需要自定义一种"新的交通方式"。
* 用户第 8 条要的"出行方式设置",实际要设的是**每种方式下的常用项**
* (比如自己常坐的车次/常用航空公司),那是"记忆上次填的"而不是"新增方式",
* 见 Setting 里那几条 last_* 记忆。所以这里用常量,界面那页做的是"提示 + 记忆"。
*/
public const TRAVEL_MODES = [
'walk' => ['步行', '🚶'],
'bike' => ['骑行', '🚲'],
'drive' => ['自驾', '🚗'],
'taxi' => ['打车', '🚕'],
'bus' => ['公交', '🚌'],
'subway' => ['地铁', '🚇'],
'train' => ['火车', '🚄'],
'plane' => ['飞机', '✈️'],
'ship' => ['轮船', '🚢'],
'other' => ['其他', '🧭'],
];
/** 自驾类(这两种才需要选车辆) */
public const DRIVE_MODES = ['drive', 'taxi'];
/**
* 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。
*
@@ -452,7 +478,15 @@ final class Trip
]);
$id = (int)$db->lastInsertId();
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
// ⚠️ 只有**真的传了** attendees 才动这份名单。
// v24 第 16 批 用户第 10 条之后,旅程表单里已经没有"同行的人"字段了 ——
// 如果这里写成 `$d['attendees'] ?? []`,每次编辑旅程都会把
// **活动级**的老名单整个清空(老数据里很多人的参与人只登记在这一层,
// 那时还没有行程级参与人)→ 用户看到的是"我一编辑,参与人全没了"。
// 新数据的参与人从行程里长出来(tripPeople 的合集口径),不受影响。
if (array_key_exists('attendees', $d)) {
self::saveAttendees($id, (array)$d['attendees']);
}
return $id;
}
@@ -471,7 +505,10 @@ final class Trip
$id, $familyId,
]);
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
// ⚠️ 同 create:没传就不动(见上面那段说明)
if (array_key_exists('attendees', $d)) {
self::saveAttendees($id, (array)$d['attendees']);
}
}
private static function saveDetails(int $eventId, array $d): void
@@ -561,6 +598,8 @@ final class Trip
$n['attachs'] = $attach[(int)$n['id']] ?? [];
}
unset($n);
// 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值")
\App\Expense::attachSums($rows);
return $rows;
}
@@ -572,6 +611,21 @@ final class Trip
return $r ?: null;
}
/**
* 保存一段行程(新增 / 编辑)。
*
* v24 第 16 批 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的
* 添加节点功能,跟那个一样」→ 字段**全量照搬**:
* 类型 / 标题 / 起止时间 / 地点 / 出行方式 + 车辆 / 线路名 / 承运方 /
* 车次航班号 / 座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(+角色)/ 照片。
* (花销走 Expense 单独增删,不在这张表单里一并提交:一笔一笔加更符合实际,
* 也避免"提交一次行程就要把整串花销重算一遍"。)
*
* ⚠️ 交通类字段(车次/座位/登机口…)**不做条件列**:用户可能先填了高铁车次,
* 后来把出行方式改成自驾 —— 那时按逻辑该清空车次,但"清空"意味着丢数据。
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
*/
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
{
$db = Database::get();
@@ -582,35 +636,79 @@ final class Trip
// ⚠️ category 只作**兼容保留**(老代码/导出还会读它),业务一律看 stage。
// 这里同步维护,保证两边不打架:stage=actual → reality,其余 → plan。
$category = $stage === 'actual' ? 'reality' : 'plan';
// 车辆:只认本家的(负数/0 是"来自财务的引用",那不是本表的行,存不进去)
$vid = (int)($d['vehicle_id'] ?? 0);
if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) {
$vid = 0;
}
$fields = [
self::textOrNull2($d['title'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
self::textOrNull($d['place'] ?? ''),
self::dtOrNull($d['starts_at'] ?? ''),
self::dtOrNull($d['ends_at'] ?? ''),
in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other',
self::nodeType($familyId, $d['node_type'] ?? ''),
in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned',
$category,
(int)($d['position'] ?? 100),
$stage,
// ---- v21 新增:交通与起终点(TripLog 同款)----
isset(self::TRAVEL_MODES[(string)($d['travel_mode'] ?? '')]) ? (string)$d['travel_mode'] : null,
$vid > 0 ? $vid : null,
self::textOrNull2($d['line_name'] ?? ''),
self::textOrNull2($d['carrier'] ?? ''),
self::textOrNull($d['trip_no'] ?? ''),
self::textOrNull($d['seat_info'] ?? ''),
self::textOrNull($d['gate'] ?? ''),
self::textOrNull2($d['from_place'] ?? ''),
self::textOrNull2($d['to_place'] ?? ''),
];
$cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?';
if ($id !== null && $id > 0) {
$db->prepare(
'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?,
node_type=?, status=?, category=?, position=?, stage=? WHERE id=?'
)->execute([...$fields, $id]);
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?')
->execute([...$fields, $id]);
$nid = $id;
} else {
$db->prepare(
'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at,
node_type, status, category, position, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
node_type, status, category, position, stage, travel_mode, vehicle_id, line_name,
carrier, trip_no, seat_info, gate, from_place, to_place)
VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
self::saveNodeMembers($nid, $d['members'] ?? []);
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
return $nid;
}
/**
* 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。
*
* ⚠️ 为什么存名字而不是 id:用户把类型改名时要级联到所有行程
* (NodeType::save 里做了),存名字让"改名"是一句 UPDATE;
* 存 id 则要在每次显示时 JOIN,而且删除类型会让老行程显示成"未知"。
* 兼容老数据:英文 key(play/move/…)原样保留 —— NodeType::iconOf 认得它。
*/
private static function nodeType(int $familyId, string $v): string
{
$v = trim($v);
if ($v === '') {
return 'other';
}
if (isset(self::NODE_TYPES[$v])) {
return $v; // 老数据的英文 key
}
// 是否是本家定义过的类型名(含刚预置的那几种)
foreach (\App\NodeType::all($familyId) as $t) {
if ((string)$t['name'] === $v) {
return $v;
}
}
return 'other';
}
// ---------------- 计划行程的三个动作(v24 第 13 批) ----------------
//
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」
@@ -780,7 +878,8 @@ final class Trip
public static function nodeMembersFor(int $nodeId, int $viewerId): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname,
m.role_tag
FROM trip_node_members m JOIN persons p ON p.id = m.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE m.node_id = ? ORDER BY p.id'
@@ -789,17 +888,79 @@ final class Trip
return $st->fetchAll();
}
public static function saveNodeMembers(int $nodeId, array $personIds): void
/**
* 存一段行程的参与人(整组替换)。
*
* @param array $roles 可选:person_id => 角色标签(TripLog 的"司机/摄影担当");
* 不传就是没有角色,与老调用完全兼容。
*/
public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void
{
$db = Database::get();
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id) VALUES (?,?)');
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
VALUES (?,?,?)');
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid > 0) {
$ins->execute([$nodeId, $pid]);
if ($pid <= 0) {
continue;
}
$role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8');
$ins->execute([$nodeId, $pid, $role !== '' ? $role : null]);
}
}
/**
* 一段旅程(活动)的**汇总信息**(v24 第 16 批 用户第 2 条)。
*
* 用户原话:「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、
* 一共几段行程,一共花了多少钱,这些信息,都从该活动下的所有行程
* 统计总结。」
*
* 五项全是从行程**算出来**的(与"时间/城市派生"同一条原则,不存冗余):
* · people —— 各段行程参与人的并集(就是 tripPeople,已有口径)
* · cities —— 各段行程去过的地点去重(未成行段不算)
* · days —— 从最早一段的开始 到 最晚一段的结束,共几天(含首尾;
* 跨天算天,同一天往返算 1 天)
* · nodes —— 行程段数(**只数会显示的**:计划 + 实际;未成行的单独给一个数)
* · cents —— 各段行程花销之和
*
* ⚠️ days 的算法要说清楚,否则"3 天"到底是几天会各人一套算法:
* 按**日历天**算(8/1 14:00 出发、8/3 10:00 回来 = 3 天),
* 不是按 24 小时整除(那样会算成 2 天,与人的直觉不符)。
*/
public static function summary(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
"SELECT MIN(starts_at) AS start_at,
MAX(COALESCE(ends_at, starts_at)) AS end_at,
SUM(CASE WHEN stage IN ('plan','actual') THEN 1 ELSE 0 END) AS shown_n,
SUM(CASE WHEN stage = 'skipped' THEN 1 ELSE 0 END) AS skipped_n
FROM trip_nodes WHERE event_id = ?"
);
$st->execute([$eventId]);
$r = $st->fetch() ?: [];
$days = 0;
$start = (string)($r['start_at'] ?? '');
$end = (string)($r['end_at'] ?? '');
if ($start !== '') {
$a = strtotime(substr($start, 0, 10));
$b = strtotime(substr($end !== '' ? $end : $start, 0, 10));
if ($a !== false && $b !== false && $b >= $a) {
// 含首尾:8/1 → 8/3 是 3 天(floor(差/86400) + 1)
$days = (int)floor(($b - $a) / 86400) + 1;
}
}
return [
'people' => self::tripPeople($eventId, $viewerId),
'cities' => self::cities($eventId),
'days' => $days,
'nodes' => (int)($r['shown_n'] ?? 0),
'skipped' => (int)($r['skipped_n'] ?? 0),
'cents' => \App\Expense::sumOfEvent($eventId),
'spent' => \App\Expense::money(\App\Expense::sumOfEvent($eventId)),
];
}
// ---------------- 展示 ----------------
@@ -844,16 +1005,57 @@ final class Trip
return $s . ' – ' . date('Y年n月j日', $te);
}
public static function typeWord(string $key): string
/**
* 行程类型的**名字**。
*
* ⚠️ 两种取值来源,顺序不能反:
* ① 用户在设置里定义的类型表(`NodeType::all()`,按名字存进 trip_nodes)——
* 这是"用户改过的真相",优先级最高;
* ② 老数据的英文 key(play/move/…)→ 内置常量表。
* 少了 ① 就会把用户自定义的类型名显示成"其他"(看起来像类型丢了)。
*
* @param int $familyId 传 0 表示"只按内置表解析"(没有家庭上下文时用)
*/
public static function typeWord(string $key, int $familyId = 0): string
{
return self::NODE_TYPES[$key][0] ?? '其他';
if (isset(self::NODE_TYPES[$key])) {
return self::NODE_TYPES[$key][0];
}
if ($familyId > 0) {
foreach (\App\NodeType::all($familyId) as $t) {
if ((string)$t['name'] === $key) {
return $key;
}
}
}
return $key !== '' ? $key : '其他';
}
public static function typeIcon(string $key): string
public static function typeIcon(string $key, int $familyId = 0): string
{
if ($familyId > 0) {
return \App\NodeType::iconOf($familyId, $key);
}
return self::NODE_TYPES[$key][1] ?? '📌';
}
/** 出行方式的名字 / 图标 */
public static function modeWord(string $key): string
{
return self::TRAVEL_MODES[$key][0] ?? '';
}
public static function modeIcon(string $key): string
{
return self::TRAVEL_MODES[$key][1] ?? '🧭';
}
/** 这个出行方式要不要选车辆 */
public static function modeNeedsVehicle(string $key): bool
{
return in_array($key, self::DRIVE_MODES, true);
}
public static function statusWord(string $key): string
{
return self::NODE_STATUS[$key] ?? '计划中';
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* 车辆库(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
* 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加
* 那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
* 比如租的车,借别人的车。」
*
* ⚠️ 车辆有**两个来源**,这里刻意**不合并**(合并 = 同一台车两处维护):
* ① 财务里的车子 —— `ledger_entries.kind='car'` 的那些**账目**。
* 它记的是"这台车值多少钱"(一台车的资产),**没有车牌、没有车型名**,
* 严格说不是"一辆能挑来出行的车"。用户说"直接读取就可以",
* 所以这里把它作为**只读候选**列出来(标成「来自财务」),
* 选中时只记一个名字,不往 vehicles 表里复制。
* ② vehicles 表 —— 真正"出行用的车":有车牌、车型名、是自家/租的/借的。
* 自家车在这里**可以**录(录了就干净、好挑),但**不强制**。
*
* 为什么不给两者加外键:账目随时可能被改名或删除(财务那边是它自己的节奏),
* 硬绑会让车辆记录跟着消失 —— 而出行的车不该因为"这台车在财务里被删了"就没了。
* 真要对上就靠车型名(人工),这是有意的松耦合。
*/
declare(strict_types=1);
namespace App;
final class Vehicle
{
/** 车辆来源:自家 / 租的 / 借别人的 */
public const TYPES = [
'own' => '自家的',
'rental' => '租的',
'borrowed' => '借的',
];
/** 一个家的车(vehicles 表里的) */
public static function all(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM vehicles WHERE family_id = ?
ORDER BY is_default DESC, vtype, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 财务里已登记的车子(**只读引用**,不复制进 vehicles)。
*
* 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。
* 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源),
* 这样模板里画下拉、画配置列表都能用同一套代码。
*/
public static function fromLedger(int $familyId): array
{
// ⚠️ 表可能还不存在(极老库 / 升级中途)→ 宽容返回空,别把"选车"整块炸掉
try {
$st = Database::get()->prepare(
"SELECT e.id, e.title, e.note FROM ledger_entries e
JOIN ledgers l ON l.id = e.ledger_id
WHERE l.family_id = ? AND e.kind = 'car'
ORDER BY e.id"
);
$st->execute([$familyId]);
} catch (\Throwable $ex) {
return [];
}
$out = [];
foreach ($st->fetchAll() as $r) {
$out[] = [
// 负数 id:与 vehicles 表的正数 id 天然不撞;选车表单提交负数即"来自财务"
'id' => -((int)$r['id']),
'ledger_id' => (int)$r['id'],
'name' => (string)$r['title'],
'plate' => '',
'vtype' => 'own',
'vendor' => null,
'order_no' => null,
'is_default'=> 0,
'note' => (string)($r['note'] ?? ''),
'from' => 'ledger', // 模板据此标「来自财务」并且不给改/删
];
}
return $out;
}
/**
* 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。
*
* @return array 每项都带 `from` = 'ledger' | 'local'
*/
public static function pickable(int $familyId): array
{
$out = [];
foreach (self::fromLedger($familyId) as $v) {
$out[] = $v;
}
foreach (self::all($familyId) as $v) {
$v['from'] = 'local';
$out[] = $v;
}
return $out;
}
public static function find(int $familyId, int $id): ?array
{
if ($id <= 0) {
return null; // 负数/0 是"来自财务的引用",不是本表的行
}
$st = Database::get()->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 「走到哪都显示得出来」的一行字:车牌 + 车型 */
public static function label(array $v): string
{
$plate = trim((string)($v['plate'] ?? ''));
$name = trim((string)($v['name'] ?? ''));
if ($plate !== '' && $name !== '') {
return $plate . ' ' . $name;
}
return $plate !== '' ? $plate : $name;
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function save(int $familyId, array $d, ?int $id = null): array
{
$name = mb_substr(trim((string)($d['name'] ?? '')), 0, 100, 'UTF-8');
if ($name === '') {
return ['ok' => false, 'error' => '车型名不能为空'];
}
$vtype = isset(self::TYPES[(string)($d['vtype'] ?? '')]) ? (string)$d['vtype'] : 'own';
$row = [
mb_substr(trim((string)($d['plate'] ?? '')), 0, 20, 'UTF-8'),
$name,
$vtype,
self::txt($d['vendor'] ?? '', 100),
self::txt($d['order_no'] ?? '', 50),
(int)($d['is_default'] ?? 0) > 0 ? 1 : 0,
self::txt($d['note'] ?? '', 500),
];
$db = Database::get();
// ⚠️ 默认车**只能有一台**:把别的都清掉再设,否则"默认"就失去意义
// (新建行程时默认选中的是哪一台,取决于 SQL 的排序运气)。
if ($row[5] === 1) {
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ?')->execute([$familyId]);
}
if ($id !== null && $id > 0) {
if (!self::find($familyId, $id)) {
return ['ok' => false, 'error' => '找不到这辆车'];
}
$db->prepare('UPDATE vehicles SET plate=?, name=?, vtype=?, vendor=?, order_no=?,
is_default=?, note=? WHERE id=? AND family_id=?')
->execute([...$row, $id, $familyId]);
return ['ok' => true, 'id' => $id];
}
$db->prepare('INSERT INTO vehicles (family_id, plate, name, vtype, vendor, order_no,
is_default, note) VALUES (?,?,?,?,?,?,?,?)')
->execute([$familyId, ...$row]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 删一辆车。
*
* ⚠️ 只用**本表**的行能被删(负数 id 是财务的引用,删它等于动财务数据 —— 不干)。
* ⚠️ 已被行程引用的车不许删:删了行程上的 `vehicle_id` 就成了悬空值
* (外键是 SET NULL,数据不丢,但用户会看到"我明明选过车,现在没了")。
* 给一句"还有 N 段行程在用",比静默清空好。
*/
public static function destroy(int $familyId, int $id): array
{
if ($id <= 0) {
return ['ok' => false, 'error' => '来自财务的车子请到财务里改'];
}
if (!self::find($familyId, $id)) {
return ['ok' => false, 'error' => '找不到这辆车'];
}
$st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
$st->execute([$id]);
$n = (int)$st->fetchColumn();
if ($n > 0) {
return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这辆车,先改掉它们再删'];
}
Database::get()->prepare('DELETE FROM vehicles WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
return ['ok' => true];
}
private static function txt($v, int $max): ?string
{
$v = mb_substr(trim((string)$v), 0, $max, 'UTF-8');
return $v !== '' ? $v : null;
}
}
+129 -1
View File
@@ -2345,8 +2345,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.trip-people { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.trip-hero { display: flex; gap: 20px; flex-wrap: wrap; }
/* ⚠️ v24 第 16 批 用户第 1 条:「题图应该按照 16:9 显示,因为添加的时候,
是按照这个比例裁剪的,现在的显示比例不对」—— 原来是 4:3 把 16:9 的图裁掉了两边。
这个数字必须与 ImageSlot 的 'cover' 槽位比例**一致**(部署断言专门比对两处)。 */
.trip-hero-cover {
width: 260px; max-width: 100%; aspect-ratio: 4 / 3; flex: 0 0 auto; border-radius: var(--radius-s);
width: 420px; max-width: 100%; aspect-ratio: 16 / 9; flex: 0 0 auto;
border-radius: var(--radius-s);
overflow: hidden; background: #F1E7D6; display: flex; align-items: center; justify-content: center;
}
.trip-hero-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -3207,3 +3211,127 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.more-set .form-grid { padding: 14px; margin: 0; }
/* 折叠区里的字段少一点内边距,看起来是"附带的"而不是主流程 */
.more-set .field { margin-bottom: 0; }
/* ============================================================
v24 第 16 批:旅程(容器)/ 行程(单元)两级
术语固定:旅程包含行程(用户第 9 条)。
新增:题图 16:9、五项统计、行程独立页、设置三页、花销。
============================================================ */
/* ---- 题图右侧的信息区:时间 + 城市 ---- */
.trip-when {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px;
font-size: 14px; color: var(--muted);
}
.trip-when-date { font-weight: 600; color: var(--ink); }
.trip-when-cities { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
.city-chip {
padding: 1px 9px; border-radius: 999px;
background: var(--line-2); color: var(--brand-ink); font-size: 12.5px;
}
.city-sep { color: var(--faint); }
/* ---- 五项统计(用户第 2 条)---- */
.trip-stats {
display: flex; flex-wrap: wrap; gap: 10px 26px;
padding: 10px 0; border-top: 1px solid var(--line-2); border-bottom: 1px solid var(--line-2);
}
.ts-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.ts-n {
font-size: 19px; font-weight: 700; color: var(--brand);
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.ts-l { font-size: 12px; color: var(--muted); }
.trip-sum { margin: 0; }
/* ---- 行程行上的新元素:出行方式 / 交通细节 / 起终点 / 花销 / 角色 ---- */
.node-mode { font-size: 12px; color: var(--muted); white-space: nowrap; }
.node-traffic { display: flex; flex-wrap: wrap; gap: 5px; }
.node-traffic .ntag {
font-size: 11.5px; padding: 1px 8px; border-radius: 5px;
background: var(--line-2); color: var(--muted);
}
.node-route-line { font-size: 12.5px; color: var(--muted); }
.node-route-line .arrow { color: var(--faint); margin: 0 3px; }
.node-spent { font-size: 12.5px; color: var(--brand-ink); }
.node-member { display: inline-flex; align-items: center; gap: 4px; }
.node-role {
font-size: 11px; padding: 0 6px; border-radius: 999px;
background: rgba(168, 74, 40, .12); color: var(--brand-ink);
}
/* ---- 行程表单:参与人角色 ---- */
.role-set { margin-top: 6px; }
.role-grid { display: grid; gap: 6px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); margin-top: 8px; }
.role-row { display: flex; align-items: center; gap: 8px; }
.role-who { flex: 0 0 72px; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.role-row .input { flex: 1 1 auto; min-width: 0; padding: 6px 9px; font-size: 13px; }
/* ---- 花销 ---- */
.exp-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.exp-row {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 7px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s);
background: #FDFBF7; font-size: 13.5px;
}
.exp-cat {
font-size: 11.5px; padding: 1px 8px; border-radius: 999px;
background: var(--line-2); color: var(--brand-ink);
}
.exp-title { flex: 1 1 140px; min-width: 0; }
.exp-amt { font-weight: 600; font-variant-numeric: tabular-nums; }
.exp-total { margin: 0 0 12px; font-size: 14px; }
.exp-form-row { display: flex; flex-wrap: wrap; gap: 8px; }
.exp-form-row .input { flex: 1 1 130px; min-width: 0; }
.exp-form-row .select { flex: 0 0 auto; width: auto; min-width: 96px; }
/* ---- 设置三页:车辆 / 出行方式 / 节点类型 ---- */
.set-sub { font-size: 14px; font-weight: 600; margin: 16px 0 8px; }
.veh-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.veh-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s);
background: #FDFBF7;
}
.veh-ro { background: var(--line-2); }
.veh-ico { font-size: 17px; }
.veh-name { font-size: 14px; font-weight: 600; }
.veh-tag { font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface); color: var(--muted); border: 1px solid var(--line); }
.veh-form { flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }
.veh-form .input { flex: 1 1 110px; min-width: 0; padding: 6px 9px; font-size: 13px; }
.veh-form .select { flex: 0 0 auto; width: auto; padding: 6px 9px; font-size: 13px; }
.veh-def { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--muted); white-space: nowrap; }
.veh-add { margin-top: 4px; }
.mode-grid { display: flex; flex-direction: column; gap: 8px; }
.mode-row {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s);
background: #FDFBF7;
}
.mode-ico { font-size: 17px; }
.mode-name { flex: 0 0 64px; font-size: 14px; }
.mode-row .input { flex: 1 1 220px; min-width: 0; padding: 6px 9px; font-size: 13px; }
.mode-none { flex: 1 1 auto; }
.type-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.type-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 11px; border: 1px solid var(--line-2); border-radius: var(--radius-s);
background: #FDFBF7;
}
.type-form { flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }
.type-form .input { flex: 1 1 140px; min-width: 0; padding: 6px 9px; font-size: 13px; }
.type-ico { flex: 0 0 52px !important; text-align: center; font-size: 16px !important; }
.type-tag { font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface); color: var(--muted); border: 1px solid var(--line); }
.type-tag-custom { background: rgba(168, 74, 40, .1); color: var(--brand-ink); border-color: rgba(168, 74, 40, .25); }
.type-used { flex: 0 0 auto; }
.type-add { margin-top: 4px; }
/* ---- 窄屏 ---- */
@media (max-width: 860px) {
/* 题图在窄屏铺满一行(信息区折到下面)—— 16:9 的比例保持不变 */
.trip-hero-cover { width: 100%; }
.trip-stats { gap: 8px 18px; }
.ts-n { font-size: 17px; }
}
+238 -28
View File
@@ -24,6 +24,7 @@ use App\ContactImporter;
use App\Crypto;
use App\Database;
use App\Douban;
use App\Expense;
use App\Family;
use App\Geo;
use App\Home;
@@ -36,6 +37,7 @@ use App\Movie;
use App\MovieReview;
use App\Module;
use App\MovieStats;
use App\NodeType;
use App\Nickname;
use App\Person;
use App\PersonGroup;
@@ -53,6 +55,7 @@ use App\Task;
use App\Trip;
use App\Upload;
use App\UserSetting;
use App\Vehicle;
use App\View;
if (!Database::hasTables()) {
@@ -2764,7 +2767,7 @@ switch ($r) {
case 'trips/new':
Auth::requireWrite();
View::render('trips/form', [
'title' => '记一次活动',
'title' => '新建旅程',
't' => null,
// 新建时默认勾上「我自己」—— 一趟旅程里几乎总有我,少点一下
'presetAttendees' => [(int)Auth::personId()],
@@ -2778,7 +2781,7 @@ switch ($r) {
$familyId = (int)Auth::familyId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
View::render('trips/form', [
@@ -2798,7 +2801,7 @@ switch ($r) {
$id = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
$t = $isEdit ? Trip::find($familyId, $id) : null;
if ($isEdit && !$t) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
$d = [
@@ -2820,14 +2823,19 @@ switch ($r) {
// ③ 勾选 → '1'。
// 之前写成 `?? ''` 会把 ① 也变成"键存在且为空" → 被当成不公开,
// 于是数据在列表里**静默消失**(实测:新建的旅程从列表与日历上都没了)。
'attendees' => filter_family_person_ids((array)($_POST['attendees'] ?? []), $familyId),
// ⚠️ v24 第 16 批 用户第 10 条:建/编辑旅程的表单里**没有**同行的人了
// ("这些都是从行程里面总结出来的")。所以这里**不再收** attendees。
// ⚠️ 但**不能**就此把活动上那份名单清空:老数据里很多人的参与人
// 只登记在这一层(那时还没有行程级参与人),清掉就"人没了"。
// 所以"键不存在"时保持原样(Trip::saveAttendees 收到空数组会全删,
// 这里刻意不传这个键)。新数据的参与人一律从行程里长出来。
];
if (array_key_exists('is_public', $_POST)) {
$d['is_public'] = (string)$_POST['is_public'];
}
$errors = [];
if ($d['title'] === '') {
$errors['title'] = '活动名字不能为空';
$errors['title'] = '旅程名字不能为空';
}
// 「回来不能早于出发」的校验一并删掉:时间不再由这里填,
// 而是从各段旅程算 max/min —— 二者反了也不可能(min ≤ max 恒成立)。
@@ -2851,7 +2859,7 @@ switch ($r) {
if ($errors) {
set_old($_POST);
View::render('trips/form', [
'title' => $isEdit ? '编辑活动' : '记一次活动',
'title' => $isEdit ? '编辑旅程' : '新建旅程',
't' => $isEdit ? array_merge($t, $d) : null,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'errors' => $errors,
@@ -2866,7 +2874,7 @@ switch ($r) {
}
$newId = Trip::create($familyId, (int)Auth::personId(), $d);
clear_old();
flash('ok', '这次活动记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
flash('ok', '这个旅程记下了。' . ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $newId]);
break;
@@ -2875,29 +2883,214 @@ switch ($r) {
$meId = (int)Auth::personId();
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), $meId);
if (!$t) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
View::render('trips/show', [
'title' => (string)$t['title'],
't' => $t,
'photos' => $t['photos'],
'nodes' => Trip::nodes((int)$t['id'], $meId),
'people' => Person::roster($familyId, $meId, 'member', ''),
// 用户第 2 条:题图右侧那五项统计(参与人/城市/天数/段数/花销)
'sm' => Trip::summary((int)$t['id'], $meId),
], 'trips');
break;
// ---------------- 行程:新增 / 编辑(独立页面,v24 第 16 批 第 5/6/7 条)----------------
//
// ⚠️ 用户第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
// 以前用的是详情页里的 <details> 内联表单 —— 每个行程一份,
// TripLog 那种十几组字段的表单内联一开就把页面撑到几屏长,
// 而且 N 份表单的元素 id 互相撞、密码管理器逐个扫描还会卡。
//
// 表单字段照 TripLog 的 nodes/form.php(用户第 7 条)。
case 'trips/node_new':
case 'trips/node_edit':
Auth::requireWrite();
$familyId = (int)Auth::familyId();
$isEdit = $r === 'trips/node_edit';
$t = null;
$nd = null;
$stage = 'plan';
if ($isEdit) {
$nd = Trip::node((int)($_GET['id'] ?? 0));
if (!$nd) {
flash('err', '找不到这段行程。');
redirect('trips');
}
// ⚠️ 必须校验这段行程确实属于**本家的**某个旅程:
// 否则知道 id 就能改别人家的行程(走的还是 Auth::requireWrite)
$t = Trip::find($familyId, (int)$nd['event_id'], (int)Auth::personId());
if (!$t) {
flash('err', '找不到这段行程。');
redirect('trips');
}
$stage = (string)($nd['stage'] ?? 'plan');
} else {
$t = Trip::find($familyId, (int)($_GET['id'] ?? 0), (int)Auth::personId());
if (!$t) {
flash('err', '找不到这个旅程。');
redirect('trips');
}
$stage = (string)($_GET['stage'] ?? 'plan');
if (!isset(Trip::NODE_STAGES[$stage]) || $stage === 'skipped') {
$stage = 'plan'; // "未成行"是标记出来的,不是新建出来的
}
}
View::render('trips/node_form', [
'title' => ($isEdit ? '编辑行程' : '添加行程') . ' · ' . (string)$t['title'],
'ev' => $t,
'n' => $nd,
'stage' => $stage,
'people' => Person::roster($familyId, (int)Auth::personId(), 'member', ''),
'types' => NodeType::all($familyId),
'vehicles' => Vehicle::pickable($familyId),
'expenses' => $nd ? Expense::ofNode((int)$nd['id']) : [],
'errors' => [],
], 'trips');
break;
// ---------------- 花销(行程级,v24 第 16 批 第 2 条)----------------
case 'trips/expense_add':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
// 校验这段行程确实属于本家(顺着 nodes → events 查一遍)
$st = Database::get()->prepare(
"SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id
WHERE n.id = ? AND n.family_id = ? AND e.family_id = ?"
);
$st->execute([$nodeId, $familyId, $familyId]);
if ($st->fetchColumn() === false) {
flash('err', '找不到这段行程。');
redirect('trips');
}
$res = Expense::add($familyId, $nodeId, [
'amount' => (string)($_POST['amount'] ?? ''),
'category' => (string)($_POST['category'] ?? ''),
'title' => (string)($_POST['title'] ?? ''),
'person_id' => (int)($_POST['person_id'] ?? 0),
]);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '花销记下了。' : (string)$res['error']);
redirect('trips/node_edit', ['id' => $nodeId]);
break;
case 'trips/expense_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
Expense::destroy($familyId, (int)($_POST['expense_id'] ?? 0));
flash('ok', '这笔花销删掉了。');
redirect('trips/node_edit', ['id' => $nodeId]);
break;
// ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)----------------
case 'trips/vehicles':
$familyId = (int)Auth::familyId();
View::render('trips/vehicles', [
'title' => '车辆管理',
'ledger' => Vehicle::fromLedger($familyId),
'mine' => Vehicle::all($familyId),
], 'trips');
break;
case 'trips/vehicles_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$res = Vehicle::save($familyId, $_POST, $id > 0 ? $id : null);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已保存。' : (string)$res['error']);
redirect('trips/vehicles');
break;
case 'trips/vehicles_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$res = Vehicle::destroy($familyId, (int)($_POST['id'] ?? 0));
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '车辆已删除。' : (string)$res['error']);
redirect('trips/vehicles');
break;
case 'trips/modes':
$familyId = (int)Auth::familyId();
// "常用信息"的记忆:每种出行方式下用户常填的值(不是新增交通方式)
View::render('trips/modes', [
'title' => '出行方式',
'modes' => Trip::TRAVEL_MODES,
'memo' => json_decode((string)Setting::get($familyId, 'trip_mode_memo', '{}'), true) ?: [],
], 'trips');
break;
case 'trips/modes_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$raw = (array)($_POST['memo'] ?? []);
$out = [];
foreach ($raw as $k => $v) {
// 只认确实存在的出行方式,值限长 —— 表单可以改,不能凭它往设置里塞任意东西
if (!isset(Trip::TRAVEL_MODES[(string)$k])) {
continue;
}
$v = mb_substr(trim((string)$v), 0, 60, 'UTF-8');
if ($v !== '') {
$out[(string)$k] = $v;
}
}
Setting::set($familyId, 'trip_mode_memo', json_encode($out, JSON_UNESCAPED_UNICODE));
flash('ok', '出行方式设置已保存。');
redirect('trips/modes');
break;
case 'trips/types':
$familyId = (int)Auth::familyId();
$types = NodeType::all($familyId);
foreach ($types as &$ty) {
$ty['used'] = NodeType::usedCount($familyId, (string)$ty['name']);
}
unset($ty);
View::render('trips/types', [
'title' => '节点类型',
'types' => $types,
], 'trips');
break;
case 'trips/types_save':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$res = NodeType::save($familyId, $_POST, $id > 0 ? $id : null);
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已保存。' : (string)$res['error']);
redirect('trips/types');
break;
case 'trips/types_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$res = NodeType::destroy($familyId, (int)($_POST['id'] ?? 0));
flash($res['ok'] ? 'ok' : 'err', $res['ok'] ? '类型已删除。' : (string)$res['error']);
redirect('trips/types');
break;
case 'trips/destroy':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::destroy($familyId, $id);
flash('ok', '这次活动已删除。');
flash('ok', '这个旅程已删除。');
redirect('trips');
break;
@@ -2907,7 +3100,7 @@ switch ($r) {
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
$warns = attach_save_files($familyId, 'trip', $id, 'photo', (int)Auth::personId());
@@ -2925,7 +3118,7 @@ switch ($r) {
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if (!Trip::find($familyId, $id)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
\App\Attachment::remove((int)($_POST['photo_id'] ?? 0));
@@ -2943,11 +3136,17 @@ switch ($r) {
$tripId = (int)($_POST['trip_id'] ?? 0);
$t = Trip::find($familyId, $tripId);
if (!$t) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
$isEdit = $r === 'trips/node_update';
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
// v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。
// 参与人的角色标签走 roles[person_id] => 角色(TripLog 的 role_tag)。
$roles = [];
foreach ((array)($_POST['roles'] ?? []) as $pid => $role) {
$roles[(int)$pid] = (string)$role;
}
$d = [
'title' => trim((string)($_POST['title'] ?? '')),
'detail' => trim((string)($_POST['detail'] ?? '')),
@@ -2957,20 +3156,31 @@ switch ($r) {
'node_type' => (string)($_POST['node_type'] ?? 'other'),
'status' => (string)($_POST['status'] ?? 'planned'),
'category' => (string)($_POST['category'] ?? 'plan'),
// v24 第 13 批:新建时用户在表单里选的「计划 / 实际」。
// 编辑时表单带的是隐藏 stage(保持原状态),也一并读。
// 新建时用户在表单里选的「计划 / 实际」;编辑时表单带的是隐藏 stage
'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'),
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
'roles' => $roles,
// ---- v21:交通与起终点(TripLog 同款)----
'travel_mode' => (string)($_POST['travel_mode'] ?? ''),
'vehicle_id' => (int)($_POST['vehicle_id'] ?? 0),
'line_name' => trim((string)($_POST['line_name'] ?? '')),
'carrier' => trim((string)($_POST['carrier'] ?? '')),
'trip_no' => trim((string)($_POST['trip_no'] ?? '')),
'seat_info' => trim((string)($_POST['seat_info'] ?? '')),
'gate' => trim((string)($_POST['gate'] ?? '')),
'from_place' => trim((string)($_POST['from_place'] ?? '')),
'to_place' => trim((string)($_POST['to_place'] ?? '')),
];
if ($d['title'] === '') {
flash('err', '这一段得有个名字。');
flash('err', '这段行程得有个名字。');
redirect('trips/show', ['id' => $tripId]);
}
$newId = Trip::saveNode($familyId, $tripId, $d, $isEdit ? $nid : null);
$warns = attach_save_files($familyId, 'trip_node', $newId, 'attach', (int)Auth::personId());
flash('ok', $isEdit ? '这一段已保存。' : '已加一段。'
$warns = attach_save_files($familyId, 'trip_node', $newId, 'photo', (int)Auth::personId());
flash('ok', ($isEdit ? '这段行程已保存。' : '行程已加进来。')
. ($warns ? '(' . implode(';', $warns) . ')' : ''));
redirect('trips/show', ['id' => $tripId]);
// ⚠️ 用户第 5 条:编辑是**独立页面**,保存后回详情页(而不是留在表单上)
redirect('trips/show', ['id' => $tripId], 'node-' . $newId);
break;
case 'trips/node_del':
@@ -2979,7 +3189,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::deleteNode((int)($_POST['id'] ?? 0));
@@ -3002,7 +3212,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
$nid = (int)($_POST['id'] ?? 0);
@@ -3025,7 +3235,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::markActual((int)($_POST['id'] ?? 0));
@@ -3040,7 +3250,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::completeNode((int)($_POST['id'] ?? 0));
@@ -3055,11 +3265,11 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::skipNode((int)($_POST['id'] ?? 0));
flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这段旅程里仍然看得到。');
flash('ok', '已标记为未成行。它不再出现在日程与往年今日,但在这个旅程里仍然看得到。');
redirect('trips/show', ['id' => $tripId]);
break;
@@ -3070,7 +3280,7 @@ switch ($r) {
csrf_check();
$tripId = (int)($_POST['trip_id'] ?? 0);
if (!Trip::find($familyId, $tripId)) {
flash('err', '找不到这次活动。');
flash('err', '找不到这个旅程。');
redirect('trips');
}
Trip::restoreNode((int)($_POST['id'] ?? 0));
-118
View File
@@ -1,118 +0,0 @@
<?php
/**
* 一段旅程的表单(新增 / 编辑共用;被 show.php 循环 require)。
*
* ⚠️ 术语(用户第 15 批 第 1 条):这是**一段旅程**;「计划 / 实际」是它的阶段。
*
* v24 第 13 批:旅程重构后,**不再分两条轨**,而是**一条记录 + 状态机**。
* 用户原话:「这个实际的行程和计划行程,是一条记录,不是两条」。
* 所以这里有「这是哪一类」的下拉:计划 / 实际
* (新建时由用户选;编辑时是当前状态)。
* 「修正」「标记为实际」「完成」「标记未成行」是**另外几个按钮**(在 show.php 上),
* 不走这张表单 —— 它们的语义是"对既有旅程的操作",不是"填一遍字段"。
*
* 变量(require 前设好):
* $nfNode 被编辑的节点数组,或 null(新增)
* $nfTripId 活动 id(events.id)
* $nfCategory 【兼容保留】'plan' | 'reality'(新版只用来定默认值)
* $nfPeople 可选人名册(选参与人用),为空就不显示成员多选
* $nfMembers 已选成员 id 列表
*/
$nfIsEdit = !empty($nfNode);
$nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : 'nn';
// 默认状态:编辑时用它的 stage;新建时按调用方给的 $nfCategory 映射(兼容旧调用)
$nfStage = $nfIsEdit
? (string)($nfNode['stage'] ?? (($nfNode['category'] ?? 'plan') === 'reality' ? 'actual' : 'plan'))
: ($nfCategory === 'reality' ? 'actual' : 'plan');
?>
<form class="node-form" method="post" action="<?= e(url($nfIsEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$nfTripId ?>">
<?php /* v24 第 13 批:新建时选「计划 / 实际」——用户原话第 2 条明确要求。
一旦创建后要改,走行程上那三个按钮(修正/完成/标记未成行),不在这张表里。 */ ?>
<input type="hidden" name="stage" value="<?= e($nfStage) ?>">
<?php if ($nfIsEdit): ?>
<input type="hidden" name="id" value="<?= (int)$nfNode['id'] ?>">
<?php endif; ?>
<div class="nf-grid">
<div class="field">
<label for="<?= e($nfPrefix) ?>-title">这段旅程叫什么 *</label>
<input class="input" id="<?= e($nfPrefix) ?>-title" name="title" required
value="<?= e($nfNode['title'] ?? '') ?>" placeholder="如:栈桥看海">
</div>
<div class="field">
<label for="<?= e($nfPrefix) ?>-type">类型</label>
<select class="select" id="<?= e($nfPrefix) ?>-type" name="node_type">
<?php foreach (\App\Trip::NODE_TYPES as $tk => $tv): ?>
<option value="<?= e($tk) ?>"<?= ($nfNode['node_type'] ?? 'other') === $tk ? ' selected' : '' ?>>
<?= e($tv[1] . ' ' . $tv[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php if (!$nfIsEdit): ?>
<div class="field">
<label for="<?= e($nfPrefix) ?>-stage">阶段 *</label>
<select class="select" id="<?= e($nfPrefix) ?>-stage" name="stage_pick">
<option value="plan"<?= $nfStage === 'plan' ? ' selected' : '' ?>>计划(还没走)</option>
<option value="actual"<?= $nfStage === 'actual' ? ' selected' : '' ?>>实际(已经走过)</option>
</select>
<span class="tip">计划走完后可用「修正 / 完成」把它转成实际。</span>
</div>
<?php else: ?>
<div class="field">
<label>当前状态</label>
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($nfStage)) ?></div>
</div>
<?php endif; ?>
<div class="field">
<label for="<?= e($nfPrefix) ?>-place">地点</label>
<input class="input" id="<?= e($nfPrefix) ?>-place" name="place"
value="<?= e($nfNode['place'] ?? '') ?>" placeholder="选填">
</div>
<div class="field">
<label for="<?= e($nfPrefix) ?>-status">状态</label>
<select class="select" id="<?= e($nfPrefix) ?>-status" name="status">
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"<?= ($nfNode['status'] ?? 'planned') === $sk ? ' selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php /* 用户 2026-10-04 第 7 条:节点的起止也是**一个范围**,一次选完 */ ?>
<div class="field dt-range">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
$dtId = $nfPrefix . '-s'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
$dtId = $nfPrefix . '-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填。</span>
</div>
<div class="field full">
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
<textarea class="input" id="<?= e($nfPrefix) ?>-detail" name="detail" rows="2"
placeholder="这段旅程发生了什么"><?= e($nfNode['detail'] ?? '') ?></textarea>
</div>
<?php if (!empty($nfPeople)): ?>
<div class="field full">
<label>这段旅程里有谁(不勾 = 整个活动同行的人)</label>
<div class="nf-members">
<?php foreach ($nfPeople as $p): ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= (int)$p['id'] ?>"
<?= in_array((int)$p['id'], $nfMembers, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit"><?= $nfIsEdit ? '保存' : '加这段旅程' ?></button>
</div>
</form>
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* 旅程模块的「设置」下拉(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」
*
* 与其它模块同一套规矩(用户 2026-10-01 定的四条):
* ① 按钮**没有文字**、只有图标,图标是**三条横线**;
* ② 排在动作区**最右**(靠 .movie-set 的 margin-left:auto);
* ③ 条目文字前都有图标;
* ④ 除设置外其它按钮都有颜色。
*
* 用法(require 前 set 好):
* $trSetCurrent 当前页 key('vehicles'|'modes'|'types'|'')
*/
$trSetCurrent = (string)($trSetCurrent ?? '');
$trSetOn = static function (string $key) use ($trSetCurrent): string {
return $trSetCurrent === $key ? ' class="on"' : '';
};
$trSetIcon = static function (string $name): string {
$paths = [
// 车辆:车头 + 两个轮
'car' => '<path d="M2.5 9.5l1.2-3.2A1.4 1.4 0 0 1 5 5.4h6a1.4 1.4 0 0 1 1.3.9l1.2 3.2"/>'
. '<path d="M1.8 9.5h12.4v3.2H1.8z"/><circle cx="4.4" cy="12.9" r="1.1"/>'
. '<circle cx="11.6" cy="12.9" r="1.1"/>',
// 出行方式:一个方向箭头
'modes' => '<path d="M2.5 8h9"/><path d="M8.5 5l3 3-3 3"/>',
// 节点类型:一排小方块
'types' => '<rect x="2" y="2" width="5" height="5" rx="1"/>'
. '<rect x="9" y="2" width="5" height="5" rx="1"/>'
. '<rect x="2" y="9" width="5" height="5" rx="1"/>'
. '<rect x="9" y="9" width="5" height="5" rx="1"/>',
];
$d = $paths[$name] ?? '';
if ($d === '') {
return '';
}
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
. ' fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
?>
<details class="movie-set menu">
<summary class="btn btn-icon" title="旅程设置" aria-label="旅程设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list">
<a href="<?= e(url('trips/vehicles')) ?>"<?= $trSetOn('vehicles') ?>><?= $trSetIcon('car') ?><span>车辆管理</span></a>
<a href="<?= e(url('trips/modes')) ?>"<?= $trSetOn('modes') ?>><?= $trSetIcon('modes') ?><span>出行方式</span></a>
<a href="<?= e(url('trips/types')) ?>"<?= $trSetOn('types') ?>><?= $trSetIcon('types') ?><span>节点类型</span></a>
</div>
</details>
<?php
// ⚠️ 只 unset **本片段自己**定义的变量(前缀 trSet*),宿主模板的变量一个都不能碰
unset($trSetCurrent, $trSetOn, $trSetIcon);
+40 -97
View File
@@ -1,48 +1,31 @@
<?php
/**
* 记一次活动 / 编辑活动(v24 第 15 批重排)。
* 新建 / 编辑旅程(v24 第 16 批精简)。
*
* ⚠️ 用户原话(2026-10-05 第 15 批):
* 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
* 「活动不需要开始和结束时间,是根据活动中的旅程…计算出来的。目的地也不需要…
* 去的城市,也是根据旅程,得到的。」
* 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
* ⚠️ 术语(用户第 9 条):这一层是**旅程**(容器),里面一段一段的是**行程**。
*
* 所以表单**主区就三样**(顺序就是用户说的顺序):
* ① 标题 ② 题图(上传 + 16:9 裁剪)
* ③ 简介
* 时间 / 城市**不在表单里** —— 它们是"旅程算出来的",不是"这里填进去的"。
* 页面上只做一句说明,让人知道这两个东西从哪来(不然会去找"怎么没地方填")。
* 用户第 10 条原话:「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,
* 这些都是从行程里面总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
* 上一批(第 4 条)已经定过主区就三项:标题 → 题图 → 简介。
* 所以现在这张表单**只剩这三项 + 公开开关**:
* · 同行的人 —— 删掉(参与人 = 各段行程参与人的合集,在详情页算出来)
* · 备注 —— 删掉(用户明确"都不要了")
* · 时间 / 城市 —— 本来就不是输入项(从行程派生)
* 「公开」保留:它是"这个旅程要不要出现在列表与日历上"的**隐私开关**,
* 不是从行程里能总结出来的信息。
*
* 「备注 / 公开 / 同行的人」功能没丢,收进「更多设置」折叠区:
* 用户说的是"就可以了"(主流程够短),不是"把这些删掉"。
* ⚠️ 但它们在同一个 <form> 里、只是视觉上折叠 —— 少了哪个字段都会静默丢数据。
*
* ⚠️ 同行的人:活动上登记的这份名单**仍然有用**(参与人 = 活动登记 ∪ 各段旅程成员)。
* 但既然活动现在"没有时间也没有地点",多数人会直接在每段旅程里填成员;
* 所以它退到折叠区,默认不占地方。
* @param array|null $t 编辑时的旅程;null = 新建
* @param array $errors 校验错误
*/
$isEdit = !empty($t);
$canWrite = \App\Auth::canWrite();
$selIds = [];
if ($isEdit) {
foreach ((array)($t['attendees'] ?? []) as $a) {
$selIds[] = (int)$a['id'];
}
} else {
$selIds = array_map('intval', (array)($presetAttendees ?? []));
}
$cvSlot = \App\ImageSlot::get('cover') ?: ['ratio' => '16:9', 'w' => 1600, 'h' => 900,
'label' => '题图', 'hint' => ''];
$hasCover = $isEdit && !empty($t['cover_key']);
// 折叠区里"有没有填过东西" —— 有就默认展开,别让改过的人还要再点一次
$moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
|| (string)($t['is_public'] ?? '1') === '0'
|| $selIds);
?>
<div class="page-head">
<h1><?= $isEdit ? '编辑活动' : '记一次活动' ?></h1>
<span class="sub">活动是容器;里面一段一段的旅程才是日程</span>
<h1><?= $isEdit ? '编辑旅程' : '新建旅程' ?></h1>
<span class="sub">旅程里装行程;参与人、城市、天数、花销都从行程里汇总</span>
<div class="actions">
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">返回</a>
@@ -59,16 +42,15 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
<?php /* ==================== ① 标题 ==================== */ ?>
<div class="field full">
<label for="t-title">这次活动叫什么 *</label>
<label for="t-title">这个旅程叫什么 *</label>
<input class="input" id="t-title" name="title" required autofocus
value="<?= e(old('title', $t['title'] ?? '')) ?>" placeholder="如:青岛之行 · 2026 暑假">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ==================== ② 题图(上传 + 裁剪)====================
⚠️ 用声明式绑定:`data-pick-input="#t-cover"` 让 app.js 的通用逻辑接管
"点图 = 打开文件选择框"。上一批这里**没有**这段绑定(JS 只写在
people/form.php 里),于是点图毫无反应 —— 用户当轮就报了这个问题。 */ ?>
<?php /* ==================== ② 题图(上传 + 16:9 裁剪)====================
⚠️ data-pick-input 让 app.js 的通用逻辑接管"点图 = 开文件框" ——
少了它会"看着能点、点了没反应"(第 15 批用户报过的那个 bug)。 */ ?>
<div class="field full">
<label>题图</label>
<div class="cover-picker">
@@ -88,8 +70,7 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
<input type="hidden" name="cover_crop" value="">
<span class="avatar-hint small muted">
点左边那块选题图,选完弹裁剪框。<br>
题图是 16:9 的宽幅横图,多大都能传,保存时自动缩成
<?= (int)$cvSlot['w'] ?>×<?= (int)$cvSlot['h'] ?>。
题图是 16:9 的宽幅横图,详情页也按 16:9 显示。
<?php if ($hasCover): ?>不换图就不用管这里。<?php endif; ?>
</span>
</div>
@@ -102,79 +83,41 @@ $moreOpen = $isEdit && (trim((string)($t['note'] ?? '')) !== ''
placeholder="这趟玩得怎么样、为什么想记下来"><?= e(old('summary', $t['summary'] ?? '')) ?></textarea>
</div>
<?php if (!$isEdit): ?>
<?php /* 时间与城市**不是输入项** —— 说清楚它们从哪来,
否则用户会满页找"怎么没地方填时间"。 */ ?>
<p class="form-note">
时间与去过的城市不用填:加进一段段旅程之后,会自动取<b>最早的一段</b>、
<b>最晚的一段</b>和<b>各段去的地方</b>算出来。
</p>
<?php else: ?>
<?php
/* 编辑时把"已经算出来什么"直接摆出来:
不显示的话,改完标题保存会以为"我把时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
?>
<p class="form-note">
当前时间:<b><?= e($tSpan) ?></b>
<?php if ($tCity !== ''): ?>· 去过:<b><?= e($tCity) ?></b><?php endif; ?>
<br>这两样都是从下面各段旅程算出来的,改这里没用;要改就去改对应的那段旅程。
</p>
<?php endif; ?>
<?php /* ==================== 更多设置(折叠)====================
功能一个都没删,只是主流程只留用户点名的那三项。 */ ?>
<details class="more-set"<?= $moreOpen ? ' open' : '' ?>>
<summary>更多设置(备注 / 公开 / 同行的人)</summary>
<div class="form-grid">
<div class="field full">
<label for="t-note">备注</label>
<textarea class="input" id="t-note" name="note" rows="3"
placeholder="选填。不显示在列表里,只在这趟活动的页面上"><?= e(old('note', $t['note'] ?? '')) ?></textarea>
</div>
<?php /* 用户 2026-10-03 第 4 条:不公开的活动只留着自己看。
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",
而不是"这个字段没出现"(后者会被当成"没传"而默认公开)。 */ ?>
<?php /* ---- 公开开关(隐私开关,不是"从行程总结"出来的信息)----
⚠️ 先放 hidden=0:不勾选时提交的是"明确的不公开",而不是"这个字段没出现"
(后者会被当成"没传"而默认公开,于是"取消勾选"看起来毫无作用)。 */ ?>
<?php $isPub = old('is_public', (string)($t['is_public'] ?? 1)) !== '0'; ?>
<div class="field full">
<label>公开</label>
<label class="chk-inline">
<input type="hidden" name="is_public" value="0">
<input type="checkbox" name="is_public" value="1" <?= $isPub ? 'checked' : '' ?>>
<span>在活动列表与日历里显示这次活动</span>
<span>在旅程列表与日历里显示这个旅程</span>
</label>
<span class="tip">不勾 = 只留着自己看:不进列表、不上日历,但有链接仍能打开。</span>
</div>
<div class="field full">
<label>同行的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<span class="tip">
这里登记的是"整趟活动都在的人"。只在某一段旅程里的人,
到那段旅程里勾就行 —— 活动页上的名单是两边合起来算的。
</span>
<?php if ($isEdit): ?>
<?php
$ppName = 'attendees[]';
$ppPeople = $people;
$ppSelected = $selIds;
$ppMode = 'multi';
$ppOpenBtn = '+ 添加同行人';
$ppTitle = '选择同行的人';
$ppTip = '';
require __DIR__ . '/../_person_picker.php';
/* 编辑时把"已经算出来什么"摆出来:不显示的话,改完标题保存会以为
"我把参与人/时间弄丢了"。 */
$tSpan = \App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null);
$tCity = (string)($t['city'] ?? '');
$tN = count((array)($t['attendees'] ?? []));
?>
<p class="form-note">
当前:<b><?= e($tSpan) ?></b><?php if ($tCity !== ''): ?> · 去过 <b><?= e($tCity) ?></b><?php endif; ?>
<?php if ($tN > 0): ?> · <b><?= $tN ?></b> 人参与<?php endif; ?>
<br>这些都是从下面各段行程算出来的,这里改不了 —— 要改就去改对应的那段行程。
</p>
<?php else: ?>
<p class="form-note">
参与人、城市、天数、花销都不用填:加进一段段<b>行程</b>之后会自动汇总。
</p>
<?php endif; ?>
</div>
</div>
</details>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '记下来' ?></button>
<button class="btn" type="submit"><?= $isEdit ? '保存' : '建好,去加行程' ?></button>
<a class="btn btn-ghost"
href="<?= e($isEdit ? url('trips/show', ['id' => (int)$t['id']]) : url('trips')) ?>">取消</a>
</div>
+15 -15
View File
@@ -1,26 +1,26 @@
<?php
/**
* 活动列表(v12 建立,v24 第 15 批把术语理清)。
* 旅程列表(v12 建立;v24 第 16 批 第 9 条把术语**固定下来**)。
*
* ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条原话):
* 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
* 所以:这一页是**活动**的列表,一条活动里头才是若干段**旅程**。
* 页面上的"趟"一律改成"次"(一次活动);"段行程"改成"段旅程"。
* ⚠️ 术语(用户第 9 条原话):「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
* 这个名字就固定下来。」
* → **旅程** = 这一页列的东西(容器);**行程** = 旅程里面的一段一段。
* (第 15 批曾把容器叫"活动",本次按用户要求改成"旅程"。)
*
* 时间与城市都是从各段旅程**派生**的(用户第 2 条)——
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是活动自己的字段。
* 时间与城市都是从各段**行程**派生出来的(第 15 批第 2 条)——
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是旅程自己的字段。
*/
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>活动</h1>
<h1>旅程</h1>
<?php /* 导航条上这个模块仍叫「旅程」(用户就是这么称呼它的),
但这一页列的顶层单位是**活动** —— 一句副标题把两级关系说清楚,
否则"导航写旅程、页面写活动"会让人以为点错了地方。 */ ?>
<span class="sub">共 <?= (int)$total ?> 次 · 每次活动里可以有若干段旅程</span>
<span class="sub">共 <?= (int)$total ?> 个 · 每个旅程里可以有若干段行程</span>
<div class="actions">
<?php if ($canWrite): ?>
<a class="btn" href="<?= e(url('trips/new')) ?>">记一次活动</a>
<a class="btn" href="<?= e(url('trips/new')) ?>">新建旅程</a>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
@@ -29,7 +29,7 @@ $canWrite = \App\Auth::canWrite();
<form class="search" method="get" action="index.php">
<input type="hidden" name="r" value="trips">
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜活动名 / 去过的城市 / 备注">
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜旅程名 / 去过的城市">
<button class="btn" type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="search-clear" href="<?= e(url('trips')) ?>">清除</a>
@@ -38,10 +38,10 @@ $canWrite = \App\Auth::canWrite();
<?php if (!$rows): ?>
<div class="card card-pad empty">
<b><?= $keyword !== '' ? '没有匹配的活动' : '还没有活动记录' ?></b>
<b><?= $keyword !== '' ? '没有匹配的旅程' : '还没有旅程' ?></b>
<?= $keyword !== '' ? '换个关键词试试,或清除搜索看全部。'
: ($canWrite ? '点右上角「记一次活动」,把一次出行记下来;进去之后再加一段段旅程。'
: '家人还没有记录活动。') ?>
: ($canWrite ? '点右上角「新建旅程」,把一次出行记下来;进去之后再加一段段行程。'
: '家人还没有记录旅程。') ?>
</div>
<?php else: ?>
<div class="trip-list">
@@ -62,7 +62,7 @@ $canWrite = \App\Auth::canWrite();
<?php if (!empty($t['city'])): ?>
<span>📍 <?= e((string)$t['city']) ?></span>
<?php endif; ?>
<span><?= (int)($t['node_count'] ?? 0) ?> 段旅程</span>
<span><?= (int)($t['node_count'] ?? 0) ?> 段行程</span>
</div>
<?php if (!empty($t['summary'])): ?>
<p class="trip-sum"><?= e(mb_substr((string)$t['summary'], 0, 80, 'UTF-8')) ?></p>
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* 出行方式设置(v24 第 16 批 用户第 8 条)。
*
* ⚠️ 这里**不是**"增删交通方式"(那是固定的物理事实,写死在 Trip::TRAVEL_MODES),
* 而是"每种方式下我常填什么"的**记忆**:
* 比如常坐的车次、常用航空公司、车牌默认选哪台。
* 用户原话要的是"设置出行方式",实际需求是"别每次重新填一遍"。
*
* @param array $modes Trip::TRAVEL_MODES
* @param array $memo 已记住的默认值(key => 值)
*/
$canWrite = \App\Auth::canWrite();
$modes = $modes ?? [];
$memo = $memo ?? [];
$back = url('trips');
?>
<div class="page-head">
<h1>出行方式</h1>
<span class="sub">交通方式是固定的;这里设的是"常填什么",省得每次重打</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
<?php $trSetCurrent = 'modes'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能改设置。</p></div>
<?php else: ?>
<form class="card card-pad" method="post" action="<?= e(url('trips/modes_save')) ?>">
<?= csrf_field() ?>
<h2 class="cinema-h2">
常用信息
<span class="hint">填了以后,新建行程选到这个方式时会自动带上</span>
</h2>
<div class="mode-grid">
<?php foreach ($modes as $mk => $mv): ?>
<div class="mode-row">
<span class="mode-ico"><?= e($mv[1]) ?></span>
<span class="mode-name"><?= e($mv[0]) ?></span>
<?php /* 只给"有可记的东西"的方式一个输入框:走路的"常用信息"没有意义 */ ?>
<?php if (in_array($mk, ['train', 'plane', 'ship', 'bus'], true)): ?>
<input class="input" name="memo[<?= e($mk) ?>]" maxlength="60"
value="<?= e((string)($memo[$mk] ?? '')) ?>"
placeholder="<?= $mk === 'plane' ? '常坐的航空公司,如 国航' : '常坐的承运方 / 车次前缀' ?>">
<?php elseif ($mk === 'drive'): ?>
<input class="input" name="memo[<?= e($mk) ?>]" maxlength="60"
value="<?= e((string)($memo[$mk] ?? '')) ?>"
placeholder="默认车辆名(空 = 用车辆库里那台「默认」)">
<?php else: ?>
<span class="mode-none small muted">无需设置</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存</button>
</div>
</form>
<?php endif; ?>
<div class="card card-pad">
<h2 class="cinema-h2">这些方式在哪用</h2>
<p class="small muted" style="margin-top:0">
在「编辑行程 / 添加行程」里选出行方式。
选了「自驾」还会多出一个车辆下拉 —— 里面既有<b>财务里登记过的车</b>,
也有「车辆管理」里自己加的车(租的、借的)。
</p>
</div>
+364
View File
@@ -0,0 +1,364 @@
<?php
/**
* 一段行程的新增 / 编辑 —— **独立页面**(v24 第 16 批 用户第 5 / 6 / 7 条)。
*
* 用户原话:
* · 第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
* · 第 6 条:「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
* · 第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
*
* ⚠️ 为什么必须独立成页(而不是继续用 `<details>` 内联):
* TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/
* 参与人/照片/状态),内联在主页面里会把整个详情页撑得极长;
* 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰
* (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。
*
* 字段顺序照 TripLog 的 nodes/form.php:
* 类型 → 起止时间 → 标题 → 出行方式(+车辆+交通细节)→ 起点 → 终点
* → 内容 → 参与人 → 照片 →(编辑时)状态与花销。
*
* 变量(require 前设好):
* $ev 所属旅程(events 行,带 title)
* $n 被编辑的行程;null = 新增
* $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的)
* $people 名册
* $types 节点类型(NodeType::all)
* $vehicles 可挑的车(Vehicle::pickable,含"来自财务"的只读候选)
* $expenses 已有花销(编辑时)
* $errors 校验错误
*/
$isEdit = !empty($n);
$canWrite = \App\Auth::canWrite();
$fid = (int)\App\Auth::familyId();
$people = $people ?? [];
$types = $types ?? [];
$vehicles = $vehicles ?? [];
$expenses = $expenses ?? [];
$errors = $errors ?? [];
$stage = (string)($stage ?? ($n['stage'] ?? 'plan'));
if (!isset(\App\Trip::NODE_STAGES[$stage])) { $stage = 'plan'; }
$curType = (string)($n['node_type'] ?? '');
$curMode = (string)($n['travel_mode'] ?? '');
$picked = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
// 参与人角色:person_id => role
$roles = [];
foreach ((array)($n['members'] ?? []) as $m) {
if (!empty($m['role_tag'])) {
$roles[(int)$m['id']] = (string)$m['role_tag'];
}
}
$back = url('trips/show', ['id' => (int)$ev['id']]);
?>
<div class="page-head">
<h1>
<?= $isEdit ? '编辑行程' : ('添加' . ($stage === 'actual' ? '实际' : '计划') . '行程') ?>
</h1>
<span class="sub">
<?= e((string)$ev['title']) ?>
<?php if ($isEdit): ?> · <?= e(\App\Trip::stageWord((string)($n['stage'] ?? $stage))) ?><?php endif; ?>
</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
</div>
</div>
<div class="card card-pad">
<form method="post" enctype="multipart/form-data"
action="<?= e(url($isEdit ? 'trips/node_update' : 'trips/node_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="trip_id" value="<?= (int)$ev['id'] ?>">
<?php /* 新增时阶段由按钮决定;编辑时是"当前状态"(只读展示,改状态走详情页那几个动作) */ ?>
<input type="hidden" name="stage" value="<?= e($stage) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<?php /* ---- 类型(TripLog 的第一个字段)---- */ ?>
<div class="field">
<label for="nf-type">类型 *</label>
<select class="select" id="nf-type" name="node_type">
<?php foreach ($types as $t): ?>
<?php $tn = (string)$t['name']; ?>
<option value="<?= e($tn) ?>"<?= $curType === $tn ? ' selected' : '' ?>>
<?= e(($t['icon'] ?? '') . ' ' . $tn) ?>
</option>
<?php endforeach; ?>
<?php /* 老数据的英文 key 不在类型表里 → 补一个当前值,否则一保存就被改成别的 */ ?>
<?php if ($curType !== '' && !in_array($curType, array_column($types, 'name'), true)): ?>
<option value="<?= e($curType) ?>" selected>
<?= e(\App\Trip::typeIcon($curType, $fid) . ' ' . \App\Trip::typeWord($curType, $fid)) ?>
</option>
<?php endif; ?>
</select>
<span class="tip">在右上角「设置 → 节点类型」里可以增删自定义。</span>
</div>
<div class="field">
<label>当前状态</label>
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($stage)) ?></div>
</div>
<?php /* ---- 标题 ---- */ ?>
<div class="field full">
<label for="nf-title">标题 *</label>
<input class="input" id="nf-title" name="title" required
value="<?= e(old('title', $n['title'] ?? '')) ?>" placeholder="例如:出发去栈桥">
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<?php /* ---- 起止时间:一个范围控件 ---- */ ?>
<div class="field dt-range full">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
$dtId = 'nf-s'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填。</span>
</div>
<?php /* ---- 出行方式 + 车辆(TripLog 的 travel_modes)----
用户第 8 条:车辆从财务里**读**,也可以加自己的(租的 / 借的)。 */ ?>
<div class="field">
<label for="nf-mode">出行方式</label>
<select class="select" id="nf-mode" name="travel_mode" data-mode-select>
<option value="">— 不填 —</option>
<?php foreach (\App\Trip::TRAVEL_MODES as $mk => $mv): ?>
<option value="<?= e($mk) ?>"<?= $curMode === $mk ? ' selected' : '' ?>>
<?= e($mv[1] . ' ' . $mv[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field" data-mode-vehicle hidden>
<label for="nf-veh">车辆</label>
<select class="select" id="nf-veh" name="vehicle_id">
<option value="">— 不指定 —</option>
<?php
$curVeh = (int)($n['vehicle_id'] ?? 0);
$ledgerShown = false;
foreach ($vehicles as $v):
$isLedger = (($v['from'] ?? '') === 'ledger');
if ($isLedger && !$ledgerShown) {
echo '<optgroup label="财务里已登记的车">';
$ledgerShown = true;
}
?>
<option value="<?= $isLedger ? (int)$v['id'] : (int)$v['id'] ?>"
<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
<?= e(\App\Vehicle::label($v) . ($isLedger ? '(来自财务)' : '')) ?>
</option>
<?php endforeach; ?>
<?php if ($ledgerShown): ?></optgroup><?php endif; ?>
</select>
<span class="tip">
财务里登记过的车子会直接列在这里;租的车、借别人的车到
「设置 → 车辆管理」加一台。
</span>
</div>
<?php /* ---- 交通细节(照 TripLog:线路名 / 车次 / 承运方 / 座位 / 登机口)---- */ ?>
<div class="field">
<label for="nf-line">线路名</label>
<input class="input" id="nf-line" name="line_name"
value="<?= e(old('line_name', $n['line_name'] ?? '')) ?>" placeholder="如:京沪高铁、地铁 3 号线">
</div>
<div class="field">
<label for="nf-tripno">车次 / 航班号</label>
<input class="input" id="nf-tripno" name="trip_no"
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
</div>
<div class="field">
<label for="nf-carrier">承运方</label>
<input class="input" id="nf-carrier" name="carrier"
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、滴滴">
</div>
<div class="field">
<label for="nf-seat">座位 / 车厢</label>
<input class="input" id="nf-seat" name="seat_info"
value="<?= e(old('seat_info', $n['seat_info'] ?? '')) ?>" placeholder="如:7 车 12F">
</div>
<div class="field">
<label for="nf-gate">登机口 / 站台</label>
<input class="input" id="nf-gate" name="gate"
value="<?= e(old('gate', $n['gate'] ?? '')) ?>" placeholder="如:T3 登机口 B12">
</div>
<?php /* ---- 起点 / 终点(TripLog 的 start/end_location)---- */ ?>
<div class="field">
<label for="nf-from">出发地</label>
<input class="input" id="nf-from" name="from_place"
value="<?= e(old('from_place', $n['from_place'] ?? '')) ?>" placeholder="如:家里">
</div>
<div class="field">
<label for="nf-to">到达地</label>
<input class="input" id="nf-to" name="to_place"
value="<?= e(old('to_place', $n['to_place'] ?? '')) ?>" placeholder="如:青岛北站">
</div>
<div class="field">
<label for="nf-place">地点</label>
<input class="input" id="nf-place" name="place"
value="<?= e(old('place', $n['place'] ?? '')) ?>" placeholder="这一段主要在哪(用于统计城市)">
<span class="tip">旅程页上的"去了几个城市"就是按这里统计的。</span>
</div>
<div class="field">
<label for="nf-status">执行状态</label>
<select class="select" id="nf-status" name="status">
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?>
<option value="<?= e($sk) ?>"<?= ($n['status'] ?? 'planned') === $sk ? ' selected' : '' ?>>
<?= e($sv) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field full">
<label for="nf-detail">记录</label>
<textarea class="input" id="nf-detail" name="detail" rows="4"
placeholder="这一段发生了什么"><?= e(old('detail', $n['detail'] ?? '')) ?></textarea>
</div>
<?php /* ---- 参与人(+角色,TripLog 的 role_tag)---- */ ?>
<div class="field full">
<label>参与的人</label>
<?php if (!$people): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<div class="nf-members">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<label class="check-item">
<input type="checkbox" name="members[]" value="<?= $pid ?>"
<?= in_array($pid, $picked, true) ? 'checked' : '' ?>>
<span><?= e(person_name($p)) ?></span>
</label>
<?php endforeach; ?>
</div>
<details class="role-set">
<summary class="link-quiet">给参与的人标个角色(可选)</summary>
<div class="role-grid">
<?php foreach ($people as $p): ?>
<?php $pid = (int)$p['id']; ?>
<div class="role-row">
<span class="role-who"><?= e(person_name($p)) ?></span>
<input class="input" name="roles[<?= $pid ?>]" maxlength="30"
value="<?= e($roles[$pid] ?? '') ?>" placeholder="如:司机 / 摄影">
</div>
<?php endforeach; ?>
</div>
<span class="tip">不填也没关系;填了会显示在那个人名字旁边。</span>
</details>
<?php endif; ?>
</div>
<?php /* ---- 照片(用户第 3 条:图片视频都针对一次具体行程)---- */ ?>
<div class="field full">
<label for="nf-photo">这一段行程的照片</label>
<?php if ($isEdit && !empty($n['attachs'])): ?>
<div class="photo-grid">
<?php foreach ($n['attachs'] as $ph): ?>
<figure class="photo-cell">
<a href="<?= e(media_url((string)$ph['key'])) ?>" data-prev="image"
data-src="<?= e(media_url((string)$ph['key'])) ?>"
data-dl="<?= e(media_url((string)$ph['key'], ['dl' => 1])) ?>"
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="">
</a>
<form method="post" action="<?= e(url('trips/photo_del')) ?>" class="photo-del">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这张照片?')">删除</button>
</form>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<input class="input" type="file" id="nf-photo" name="photo[]" accept="image/*" multiple>
<span class="tip">可多选;图片、视频都会存到这段行程下面。</span>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEdit ? '保存' : '加这段行程' ?></button>
<a class="btn btn-ghost" href="<?= e($back) ?>">取消</a>
</div>
</form>
</div>
<?php /* ==================== 花销(只在编辑时出现)====================
用户第 2 条要"一共花了多少钱"、本次确认「行程级花销记录」。
⚠️ 新增时不给花销面板:那时行程 id 还不存在,花销挂不上(先存行程再加花销,
少一种"提交了但没存上"的状态)。 */ ?>
<?php if ($isEdit): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
花销(<?= count($expenses) ?> 笔)
<span class="hint">这一段花了多少钱;旅程页的"总花销"是各段之和</span>
</h2>
<?php if (!$expenses): ?>
<p class="empty-hint">还没记花销。</p>
<?php else: ?>
<ul class="exp-list">
<?php foreach ($expenses as $ex): ?>
<li class="exp-row">
<span class="exp-cat"><?= e(\App\Expense::CATS[(string)$ex['category']] ?? '其他') ?></span>
<span class="exp-title"><?= e((string)($ex['title'] ?? '')) ?></span>
<?php if (!empty($ex['person_name'])): ?>
<span class="exp-who small muted"><?= e((string)$ex['person_name']) ?> 付</span>
<?php endif; ?>
<span class="exp-amt"><?= e(\App\Expense::money((int)$ex['amount_cents'])) ?></span>
<form method="post" action="<?= e(url('trips/expense_del')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="node_id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="expense_id" value="<?= (int)$ex['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔花销?')">删</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<p class="exp-total">合计 <b><?= e(\App\Expense::money(\App\Expense::sumOfNode((int)$n['id']))) ?></b></p>
<?php endif; ?>
<form method="post" action="<?= e(url('trips/expense_add')) ?>" class="exp-form">
<?= csrf_field() ?>
<input type="hidden" name="node_id" value="<?= (int)$n['id'] ?>">
<div class="exp-form-row">
<input class="input" name="amount" required placeholder="金额,如 128.50" inputmode="decimal">
<select class="select" name="category" aria-label="类别">
<?php foreach (\App\Expense::CATS as $ck => $cv): ?>
<option value="<?= e($ck) ?>"><?= e($cv) ?></option>
<?php endforeach; ?>
</select>
<?php /* ⚠️ 「谁付的」也是选人 —— 必须走**全局选人组件**(v24 第 8 条:
全站的选人一律弹窗 + 头像 + 拼音联想,不许再写裸 <select>)。
裸下拉的判据在部署断言里,写完空跑会直接红。 */ ?>
<div class="exp-who">
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '谁付的(可不选)';
$ppTitle = '这笔是谁付的';
$ppEmptyText = '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<input class="input" name="title" placeholder="花在哪(可空)">
<button class="btn btn-sm" type="submit">加一笔</button>
</div>
</form>
</div>
<?php endif; ?>
+190 -153
View File
@@ -1,53 +1,81 @@
<?php
/**
* 活动详情(v12 建立,v24 第 15 批整理术语与派生值)。
* 旅程详情(v24 第 16 批重做)。
*
* 页面结构:封面 + 简介 + 同行的人 + 照片 + 下面的若干段**旅程**。
* ⚠️ 术语(用户第 9 条:「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
* 这个名字就固定下来」):
* · **旅程** = 这一页(原来叫"活动")
* · **行程** = 里面的一段一段(原来叫"旅程")
*
* ⚠️ 术语(用户 2026-10-05 第 15 批 第 1 条):这里是**一次活动**,
* 里头一段一段的才是**旅程**。所以「+ 加一段计划行程」这类按钮
* 统一改成「+ 加一段旅程」(阶段用「计划 / 实际」修饰)。
* 页面结构(用户第 1/2/3/6 条):
* ① 页头:标题 + 右上角按钮(计划行程 / 实际行程 / 设置 / 编辑 / 删除)
* —— 标题旁边**不放时间**(第 6 条:时间移到内容里、题图右侧)
* ② 封面行:左 16:9 题图 + 右侧信息区
* 信息区 = 参与人 / 城市数 / 天数 / 行程段数 / 总花销 + 简介那一段话
* ③ 行程分组:**只显示非空的分组**(第 4 条:"只要是没有就是 0 条,
* 那个类型就不显示了,现在显示的是 0 条,这样没意义")
* ④ **没有照片上传板块**(第 3 条:图片视频都针对一次具体行程)
*
* ⚠️ 时间与城市都来自 $t 上挂的**派生值**(Trip::derived(),用户第 2 条)——
* 页面不再读 events.starts_at / location。
* @param array $t 旅程(events + trip_details + 派生值)
* @param array $nodes 全部行程(已带上成员/附件/花销)
* @param array $sm 汇总信息(Trip::summary())
*/
$canWrite = \App\Auth::canWrite();
// v24 第 13 批:不再按 category 双轨分,按**状态机 stage** 分三组展示。
// 计划行程 / 实际行程 / 未成行(未成行的也要显示,用户明确要求"只不过要标记上")。
$fid = (int)\App\Auth::familyId();
$sm = $sm ?? [];
$people = $sm['people'] ?? [];
$cities = $sm['cities'] ?? [];
$days = (int)($sm['days'] ?? 0);
$nodeN = (int)($sm['nodes'] ?? 0);
$cents = (int)($sm['cents'] ?? 0);
// 三组:计划 / 实际 / 未成行
$bysStage = ['plan' => [], 'actual' => [], 'skipped' => []];
foreach ((array)($nodes ?? []) as $n) {
$sg = (string)($n['stage'] ?? (($n['category'] ?? 'plan') === 'reality' ? 'actual' : 'plan'));
$sg = (string)($n['stage'] ?? 'plan');
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
$bysStage[$sg][] = $n;
}
// 每条行程带上它的修正历史(模板里画"原计划 → 实际"的对比)
foreach ($bysStage as $sg => &$lst) {
foreach ($lst as &$nd) {
$nd['revisions'] = \App\Trip::revisions((int)$nd['id']);
}
unset($nd);
}
unset($lst);
$memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'id'));
$groupMeta = [
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
];
?>
<div class="page-head">
<h1><?= e((string)$t['title']) ?></h1>
<?php /* 时间/城市都是派生的:一段旅程都没加时显示「还没有旅程」,不是「日期待补」 */ ?>
<span class="sub"><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<?php /* ⚠️ 用户第 6 条:标题旁边**不放**时间。时间在下面题图右侧的信息区里。 */ ?>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 用户第 6 条:添加计划行程 / 实际行程的按钮都放**右上角**,
点击打开**新页面**(第 5 条:不再内联,内联会把页面撑破)。 */ ?>
<a class="btn btn-ghost" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'plan'])) ?>">
+ 计划行程
</a>
<a class="btn" href="<?= e(url('trips/node_new', ['id' => (int)$t['id'], 'stage' => 'actual'])) ?>">
+ 实际行程
</a>
<a class="btn btn-ghost" href="<?= e(url('trips/edit', ['id' => (int)$t['id']])) ?>">编辑</a>
<form method="post" action="<?= e(url('trips/destroy')) ?>"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的旅程、照片会一起删掉,不可恢复。')">
<?php /* 用户第 8 条:右上角增加一个**设置**按钮(车辆管理 / 出行方式 / 节点类型)。
按全站规矩:设置按钮只有图标(三条横线)、无文字、贴最右。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
<form method="post" action="<?= e(url('trips/destroy')) ?>" class="res-inline"
onsubmit="return confirm('确定删除「<?= e((string)$t['title']) ?>」吗?里面的行程、照片会一起删掉,不可恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-danger" type="submit">删除</button>
<button class="btn btn-ghost btn-icon" type="submit" title="删除这个旅程" aria-label="删除">🗑</button>
</form>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回活动</a>
</div>
</div>
<?php /* ==================== 封面行:题图 + 信息区 ====================
⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的,
现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。
16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**,
部署断言里有一条专门比对它。 */ ?>
<div class="card card-pad trip-hero">
<div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?>
@@ -56,145 +84,173 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
<span class="trip-cover-empty">🧳</span>
<?php endif; ?>
</div>
<div class="trip-hero-body">
<?php /* 地名可能是好几个城市 → 用派生出来的列表逐颗画(用户第 2 条) */ ?>
<?php if (!empty($t['cities'])): ?>
<p class="trip-loc">
<?php foreach ((array)$t['cities'] as $i => $city): ?>
<?= $i > 0 ? '<span class="city-sep">·</span>' : '📍' ?>
<span class="city-chip"><?= e((string)$city) ?></span>
<?php /* ---- 时间与城市:从行程算出来的(第 15 批第 2 条);
第 6 条要求放在这里(题图右侧)而不是标题旁 ---- */ ?>
<div class="trip-when">
<span class="trip-when-date"><?= e(\App\Trip::activitySpan($t['starts_at'] ?? null, $t['ends_at'] ?? null)) ?></span>
<?php if ($cities): ?>
<span class="trip-when-cities">
<?php foreach ($cities as $i => $c): ?>
<?= $i > 0 ? '<span class="city-sep">·</span>' : '' ?><span class="city-chip"><?= e((string)$c) ?></span>
<?php endforeach; ?>
</p>
</span>
<?php endif; ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?>
<?php if (!empty($t['attendees'])): ?>
<?php /* v24 第 14 批 用户第 4 条:「一个活动的参与人,应该是这个活动下的
所有旅程的参与人的合集」—— 这上面的名单是**每段行程参与人的并集**,
在行程里加了人,这里自动出现,不用再到活动上登记一遍。 */ ?>
</div>
<?php /* ---- 五项统计(用户第 2 条):参与人 / 城市 / 天数 / 段数 / 花销 ---- */ ?>
<div class="trip-stats">
<div class="ts-item">
<span class="ts-n"><?= count($people) ?></span>
<span class="ts-l">参与人</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= count($cities) ?></span>
<span class="ts-l">个城市</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= $days > 0 ? $days : '—' ?></span>
<span class="ts-l">天</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= $nodeN ?></span>
<span class="ts-l">段行程</span>
</div>
<div class="ts-item">
<span class="ts-n"><?= e(\App\Expense::money($cents)) ?></span>
<span class="ts-l">总花销</span>
</div>
</div>
<?php /* 参与人是谁:用户点名要("显示的是参与人都有谁") */ ?>
<?php if ($people): ?>
<div class="trip-people-block">
<span class="small muted">参与的人(各段行程的合集)</span>
<div class="viewers-list">
<?php foreach ($t['attendees'] as $a): ?>
<?php foreach ($people as $a): ?>
<?= person_chip($a, url('people/show', ['id' => (int)$a['id']])) ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php /* ---- 简介:用户第 2 条明确"简介那一段话保留" ---- */ ?>
<?php if (!empty($t['summary'])): ?>
<p class="info-text trip-sum"><?= nl2br(e((string)$t['summary'])) ?></p>
<?php endif; ?>
<?php if (!empty($t['note'])): ?>
<p class="remark-text"><?= nl2br(e((string)$t['note'])) ?></p>
<?php endif; ?>
</div>
</div>
<?php /* ---- 照片 ---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">照片</h2>
<?php if (!$photos): ?>
<p class="empty-hint">还没有照片。</p>
<?php else: ?>
<div class="photo-grid">
<?php foreach ($photos as $ph): ?>
<figure class="photo-cell">
<?php /* v24 第 3 条:照片也在**本页**预览(原来 target=_blank 是另开一个标签页
只显示一张图,既没有下载按钮也不好回来看) */ ?>
<a href="<?= e(media_url((string)$ph['key'])) ?>"
data-prev="image"
data-src="<?= e(media_url((string)$ph['key'])) ?>"
data-dl="<?= e(media_url((string)$ph['key'], ['dl' => 1])) ?>"
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>">
</a>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('trips/photo_del')) ?>" class="photo-del">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$ph['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这张照片?')">删除</button>
</form>
<?php endif; ?>
</figure>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?>
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('trips/photo_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input type="file" name="photo" accept="image/*" multiple required>
<button class="btn btn-sm" type="submit">上传照片</button>
</form>
<?php endif; ?>
</div>
<?php /* ==================== 这个活动里的旅程(v24 第 13 批重构)====================
⚠️ 术语(用户第 15 批 第 1 条):这一块里每一条都是**一段旅程**,
不是"行程";分三组是按它的**阶段**(计划 / 实际 / 未成行)。
用户 2026-10-05(第 13 批)原话要点:
· 刚开始添加时选「计划 / 实际」
· 计划的三段有三个按钮:删除(**标记未成行,不真删**)、修正(记实际执行,可无限次)、
完成(实际=计划,直接变实际)
· 未成行的仍显示、但带标记;不上日程、不进往年今日
· 实际的会同时上日程 + 往年今日
分三组渲染(未成行放最后,用灰调)。 */ ?>
<?php /* ==================== 行程分组 ====================
用户第 4 条:「只要是没有就是 0 条,那个类型就不显示了,现在显示的是 0 条,这样没意义。」
⚠️ 所以这里**跳过空分组**。但要给"一段都还没有"的情况一句引导 ——
否则新建的旅程页面上会一片空白,人不知道该干什么。 */ ?>
<?php
$stageMeta = [
'plan' => ['计划的旅程', '还没走。每段下面的「修正 / 完成 / 标记未成行」都能用'],
'actual' => ['走过的旅程', '已走过 —— 会出现在日历与首页「往年今日」'],
'skipped' => ['没成行的', '这次没去成;不再出现在日程与「往年今日」,但在这里保留可查'],
];
foreach (['plan', 'actual', 'skipped'] as $sg):
$list = $bysStage[$sg];
$shown = 0;
foreach ($bysStage as $lst) { $shown += count($lst); }
?>
<?php if ($shown === 0): ?>
<div class="card card-pad empty">
<b>还没有行程</b>
<?php if ($canWrite): ?>
点右上角「+ 计划行程」把打算走的地方排进来,或者「+ 实际行程」直接记已经走过的。
参与人、城市、天数、花销都会从行程里自动汇总到这里。
<?php else: ?>
家人还没有记录行程。
<?php endif; ?>
</div>
<?php else: ?>
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?>
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
<h2 class="cinema-h2">
<?= e($stageMeta[$sg][0]) ?>
<?= e($groupMeta[$sg][0]) ?>
<span class="group-n"><?= count($list) ?> 段</span>
</h2>
<p class="stage-hint"><?= e($stageMeta[$sg][1]) ?></p>
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
<?php if (!$list): ?>
<p class="empty-hint">
<?= $sg === 'plan' ? '还没有排计划。点下面的「+ 加一段旅程」,把打算走的地方排进来。'
: ($sg === 'actual' ? '还没有走过的旅程。' : '没有未成行的。') ?>
</p>
<?php else: ?>
<ol class="node-list">
<?php foreach ($list as $n): ?>
<?php
$nid = (int)$n['id'];
$span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
$mode = (string)($n['travel_mode'] ?? '');
$spent = (int)($n['spent_cents'] ?? 0);
?>
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
id="node-<?= (int)$n['id'] ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
id="node-<?= $nid ?>">
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>">
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?>
</span>
<div class="node-body">
<div class="node-head">
<b><?= e((string)$n['title']) ?></b>
<span class="node-badge nb-<?= e($sg) ?>"><?= e(\App\Trip::stageWord($sg)) ?></span>
<span class="node-badge nb-<?= e($sg) ?>"><?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?></span>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<span class="rev-badge" title="已修正过多次,点下方「看修正记录」查看">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
<?php endif; ?>
<?php if (!empty($n['place'])): ?>
<span class="node-place">📍 <?= e((string)$n['place']) ?></span>
<?php endif; ?>
<?php if ($mode !== ''): ?>
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></span>
<?php endif; ?>
</div>
<?php $span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null); ?>
<?php if ($span !== '日期待补'): ?>
<div class="node-time">
<?= e($span) ?>
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
</div>
<?php endif; ?>
<?php /* 交通细节(TripLog 同款):只显示**填过的**那几个,没填就不占地方 */ ?>
<?php
$traffic = [];
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_no']; }
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
if (!empty($n['seat_info'])) { $traffic[] = '座位 ' . (string)$n['seat_info']; }
if (!empty($n['gate'])) { $traffic[] = '登机口/站台 ' . (string)$n['gate']; }
?>
<?php if ($traffic): ?>
<div class="node-traffic">
<?php foreach ($traffic as $tf): ?><span class="ntag"><?= e($tf) ?></span><?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['from_place']) || !empty($n['to_place'])): ?>
<div class="node-route-line">
<?= e((string)($n['from_place'] ?? '—')) ?> <span class="arrow">→</span> <?= e((string)($n['to_place'] ?? '—')) ?>
</div>
<?php endif; ?>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
<?php endif; ?>
<?php if ($spent !== 0): ?>
<div class="node-spent">💰 这一段花了 <?= e(\App\Expense::money($spent)) ?></div>
<?php endif; ?>
<?php if (!empty($n['members'])): ?>
<div class="trip-people">
<?php foreach ($n['members'] as $mm): ?><?= person_chip($mm) ?><?php endforeach; ?>
<?php foreach ($n['members'] as $mm): ?>
<span class="node-member">
<?= person_chip($mm) ?>
<?php if (!empty($mm['role_tag'])): ?>
<span class="node-role"><?= e((string)$mm['role_tag']) ?></span>
<?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['attachs'])): ?>
<?php
$attRows = $n['attachs'];
@@ -204,7 +260,7 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
?>
<?php endif; ?>
<?php /* ---- 修正记录:原计划 → 每次修正的对比 ---- */ ?>
<?php /* ---- 修正记录对比(第 13 批) ---- */ ?>
<?php if (!empty($n['revisions'])): ?>
<details class="rev-hist">
<summary class="link-quiet">看修正记录(<?= count($n['revisions']) ?> 条)</summary>
@@ -229,7 +285,10 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
<?php if ($canWrite): ?>
<div class="node-ops">
<?php /* ---- 计划行程:三个动作(用户点名的三个按钮)---- */ ?>
<?php /* 用户第 5 条:**编辑行程打开新页面**(内联的编辑面板会把页面撑破) */ ?>
<a class="btn btn-ghost btn-sm"
href="<?= e(url('trips/node_edit', ['id' => $nid])) ?>">编辑</a>
<?php if ($sg === 'plan'): ?>
<details class="rev-open">
<summary class="btn btn-ghost btn-sm">修正</summary>
@@ -237,7 +296,7 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
<p class="rev-tip">
记录实际执行情况。这仍与上面那条计划是<b>同一条记录</b>,
原来的计划数据会保留在「修正记录」里,方便日后对比。
点完这里<b>这段旅程仍是计划</b>;确认无误后再点「标记为实际」。
点完这里<b>这段行程仍是计划</b>;确认无误后再点「标记为实际」。
</p>
<?php
$rvNode = $n; $rvTripId = (int)$t['id'];
@@ -247,71 +306,49 @@ foreach (['plan', 'actual', 'skipped'] as $sg):
?>
</div>
</details>
<form method="post" action="<?= e(url('trips/node_done')) ?>"
<form method="post" action="<?= e(url('trips/node_done')) ?>" class="res-inline"
onsubmit="return confirm('实际执行与计划完全相同,直接转为实际?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm" type="submit">完成</button>
</form>
<form method="post" action="<?= e(url('trips/node_skip')) ?>"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个活动里仍然看得到。')">
<form method="post" action="<?= e(url('trips/node_skip')) ?>" class="res-inline"
onsubmit="return confirm('标记为「未成行」?它不会再出现在日程与往年今日,但在这个旅程里仍然看得到。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">标记未成行</button>
</form>
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
<form method="post" action="<?= e(url('trips/node_actual')) ?>">
<form method="post" action="<?= e(url('trips/node_actual')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-sm btn-primary" type="submit">标记为实际</button>
</form>
<?php endif; ?>
<?php elseif ($sg === 'skipped'): ?>
<form method="post" action="<?= e(url('trips/node_restore')) ?>">
<form method="post" action="<?= e(url('trips/node_restore')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">恢复为计划</button>
</form>
<?php endif; ?>
<details class="node-edit">
<summary class="link-quiet">编辑</summary>
<?php
$nfNode = $n; $nfTripId = (int)$t['id']; $nfCategory = $sg;
$nfPeople = $people ?? [];
$nfMembers = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
require __DIR__ . '/_node_form.php';
?>
</details>
<form method="post" action="<?= e(url('trips/node_del')) ?>"
onsubmit="return confirm('彻底删除这一段旅程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「标记未成行」。')">
<form method="post" action="<?= e(url('trips/node_del')) ?>" class="res-inline"
onsubmit="return confirm('彻底删除这段行程?连同它的修正记录一起消失,不可恢复。\n\n如果你只是想表达「这次没去成」,请用「标记未成行」。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<input type="hidden" name="id" value="<?= $nid ?>">
<input type="hidden" name="trip_id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit">彻底删除</button>
<button class="btn btn-ghost btn-sm" type="submit">删除</button>
</form>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<?php if ($canWrite && $sg !== 'skipped'): ?>
<details class="node-add">
<?php /* 术语:加的是"一段旅程",阶段只是它的属性 */ ?>
<summary class="link-quiet">+ 加一段旅程(<?= $sg === 'actual' ? '实际' : '计划' ?>)</summary>
<?php
$nfNode = null; $nfTripId = (int)$t['id']; $nfCategory = $sg;
$nfPeople = $people ?? []; $nfMembers = $memberIds;
require __DIR__ . '/_node_form.php';
?>
</details>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* 节点类型设置(v24 第 16 批 用户第 8 条)。
*
* 用户第 7 条要求照 TripLog 的添加节点功能,TripLog 的节点类型是**可自定义的**:
* 内置几种(游玩/用餐/赶路/住宿/其他)+ 用户自己加。
*
* ⚠️ 内置的不许删(删了会被自动预置回来,看起来像"删不掉");
* 还在被行程使用的也不许删(行程里存的是名字,删了那些行程就显示成"其他")。
*
* @param array $types NodeType::all()(带 used 计数)
*/
$canWrite = \App\Auth::canWrite();
$types = $types ?? [];
$back = url('trips');
?>
<div class="page-head">
<h1>节点类型</h1>
<span class="sub">行程分几类,你自己定</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
<?php $trSetCurrent = 'types'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能改设置。</p></div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">现有类型</h2>
<ul class="type-list">
<?php foreach ($types as $t): ?>
<li class="type-row">
<form class="inline-form type-form" method="post" action="<?= e(url('trips/types_save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<input class="input type-ico" name="icon" maxlength="4"
value="<?= e((string)($t['icon'] ?? '')) ?>" aria-label="图标">
<input class="input" name="name" value="<?= e((string)$t['name']) ?>"
required aria-label="类型名">
<?php if ((int)$t['is_system'] === 1): ?>
<span class="type-tag">内置</span>
<?php else: ?>
<span class="type-tag type-tag-custom">自定义</span>
<?php endif; ?>
<span class="type-used small muted">
<?= (int)($t['used'] ?? 0) ?> 段在用
</span>
<button class="btn btn-sm" type="submit">保存</button>
</form>
<?php if ((int)$t['is_system'] === 0): ?>
<form class="inline-form" method="post" action="<?= e(url('trips/types_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉类型「<?= e((string)$t['name']) ?>」?')">删</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<p class="small muted">
内置的几种不能删(可以改名)。已经有行程在用的类型也不能删 ——
先到那些行程里把它们改成别的类型。
</p>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">加一个类型</h2>
<form class="inline-form type-form type-add" method="post" action="<?= e(url('trips/types_save')) ?>">
<?= csrf_field() ?>
<input class="input type-ico" name="icon" maxlength="4" placeholder="🎣" aria-label="图标">
<input class="input" name="name" placeholder="类型名,如「赶海」" required aria-label="类型名">
<button class="btn btn-sm" type="submit">加进类型库</button>
</form>
<p class="tip">图标填一个表情符号就行(如 🎣 🏖 🍜)。</p>
</div>
<?php endif; ?>
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* 车辆管理(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加
* 那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
* 比如租的车,借别人的车。」
*
* 所以这一页有两块:
* ① **财务里已登记的车**(只读)—— 只列出来,不给改不给删(那是财务的数据);
* ② **出行用的车**(本表)—— 自家 / 租的 / 借的,可增删改。
*
* @param array $ledger Vehicle::fromLedger() 的结果(只读)
* @param array $mine Vehicle::all() 的结果
*/
$canWrite = \App\Auth::canWrite();
$ledger = $ledger ?? [];
$mine = $mine ?? [];
$back = url('trips');
?>
<div class="page-head">
<h1>车辆管理</h1>
<span class="sub">出行时能挑的车:自家的、租的、借别人的</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e($back) ?>">返回旅程</a>
<?php $trSetCurrent = 'vehicles'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* ---- ① 来自财务的车(只读引用)---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
财务里已登记的车
<span class="hint">只读 —— 要改请到财务模块</span>
</h2>
<?php if (!$ledger): ?>
<p class="empty-hint">
财务里还没有登记车子。到「财务 → 记一笔」里把车子作为一项资产记下来,
这里就会自动出现(<b>不必在这里重复添加</b>)。
</p>
<?php else: ?>
<ul class="veh-list">
<?php foreach ($ledger as $v): ?>
<li class="veh-row veh-ro">
<span class="veh-ico">🚗</span>
<span class="veh-name"><?= e((string)$v['name']) ?></span>
<span class="veh-tag">来自财务</span>
</li>
<?php endforeach; ?>
</ul>
<p class="small muted">
这些是财务里的**资产记录**(有一台车值多少钱),不一定有车牌与车型。
出行要选车时它们也会出现在下拉里,标着「来自财务」。
</p>
<?php endif; ?>
</div>
<?php /* ---- ② 出行用的车(本表)---- */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
出行用的车
<span class="hint">自家车可以录一台方便选;租的车、借的车录在这里</span>
</h2>
<?php if ($mine): ?>
<ul class="veh-list">
<?php foreach ($mine as $v): ?>
<li class="veh-row">
<span class="veh-ico"><?= ($v['vtype'] ?? 'own') === 'rental' ? '🔑' : (($v['vtype'] ?? 'own') === 'borrowed' ? '🤝' : '🚗') ?></span>
<?php if ($canWrite): ?>
<form class="inline-form veh-form" method="post" action="<?= e(url('trips/vehicles_save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$v['id'] ?>">
<input class="input" name="plate" value="<?= e((string)$v['plate']) ?>"
placeholder="车牌" aria-label="车牌">
<input class="input" name="name" value="<?= e((string)$v['name']) ?>"
placeholder="车型名" required aria-label="车型名">
<select class="select" name="vtype" aria-label="来源">
<?php foreach (\App\Vehicle::TYPES as $vk => $vv): ?>
<option value="<?= e($vk) ?>"<?= (string)$v['vtype'] === $vk ? ' selected' : '' ?>>
<?= e($vv) ?>
</option>
<?php endforeach; ?>
</select>
<input class="input" name="vendor" value="<?= e((string)($v['vendor'] ?? '')) ?>"
placeholder="租车公司 / 车主" aria-label="租车公司或车主">
<label class="veh-def">
<input type="checkbox" name="is_default" value="1"<?= (int)$v['is_default'] === 1 ? ' checked' : '' ?>>
<span>默认</span>
</label>
<button class="btn btn-sm" type="submit">保存</button>
</form>
<form class="inline-form" method="post" action="<?= e(url('trips/vehicles_del')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$v['id'] ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这辆车?')">删</button>
</form>
<?php else: ?>
<span class="veh-name"><?= e(\App\Vehicle::label($v)) ?></span>
<span class="veh-tag"><?= e(\App\Vehicle::TYPES[(string)$v['vtype']] ?? '') ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="empty-hint">还没有。自家车录一台,出行时能直接挑。</p>
<?php endif; ?>
<?php if ($canWrite): ?>
<h3 class="set-sub">加一辆车</h3>
<form class="inline-form veh-form veh-add" method="post" action="<?= e(url('trips/vehicles_save')) ?>">
<?= csrf_field() ?>
<input class="input" name="plate" placeholder="车牌(借的车可不填)" aria-label="车牌">
<input class="input" name="name" placeholder="车型名,如「大众朗逸」" required aria-label="车型名">
<select class="select" name="vtype" aria-label="来源">
<?php foreach (\App\Vehicle::TYPES as $vk => $vv): ?>
<option value="<?= e($vk) ?>"><?= e($vv) ?></option>
<?php endforeach; ?>
</select>
<input class="input" name="vendor" placeholder="租车公司 / 车主" aria-label="租车公司或车主">
<input class="input" name="order_no" placeholder="租车订单号" aria-label="租车订单号">
<label class="veh-def">
<input type="checkbox" name="is_default" value="1">
<span>设为默认</span>
</label>
<button class="btn btn-sm" type="submit">加进车辆库</button>
</form>
<?php endif; ?>
</div>