行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)

#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
This commit is contained in:
2026-11-05 12:40:57 +08:00
parent a1140437a1
commit f58a128816
9 changed files with 1361 additions and 46 deletions
+322 -1
View File
@@ -102,6 +102,17 @@ final class ApiHandler
case 'movies': $this->movies($id, $sub, $method); return;
case 'cinemas': $this->cinemas($id, $method); return;
case 'trips': $this->trips($id, $sub, $method); return;
/* 🔴 2026-11-05:**暂存上传**(用户要求"先上传拿到地址,保存时再挂")。
网页端的拖拽上传走的就是这条:文件先落暂存区拿 stoken,
保存表单时把 stoken 一起提交,后端建好宿主(行程节点)后
再 claim 到它名下。
⚠️ 必须单独一个资源(`/uploads/stage`)而不是挂在 trips 下面:
`Api::dispatch()` 只给三段,挂 `/trips/<id>/stage` 会和
"某趟旅程的某个子动作"语义混淆;而暂存文件是**与宿主无关**的。
⚠️ 只能上传,不能列举/删除 —— 暂存区是内部中间态,不暴露管理接口。 */
case 'uploads': $this->uploads($sub, $method); return;
/* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。
⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 ——
`Api::dispatch()` 只解析三段(`/<res>/<id>/<sub>`),
@@ -122,6 +133,9 @@ final class ApiHandler
必须验旧密码、两次新密码一致、且过 `Account::validatePassword()`
(≥8 位 + 字母与数字都有)—— 端上不该比网页松。 */
case 'account': $this->account($sub, $method); return;
/* 🔴 2026-11-03:地址联想(端上地点输入框的下拉候选)。
频控口径见 `geo()` 的注释(每小时 300 次)。 */
case 'geo': $this->geo($method); return;
}
Api::fail('未知的接口资源:' . $res, 'not_found', 404);
@@ -1371,6 +1385,54 @@ final class ApiHandler
]);
}
/**
* 地址联想:`GET /api/v1/geo/suggest?q=招商银行&limit=8`
*
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 `1002600003`(AGC 认证失败),
* 而 AGC 那条链路要开地图服务 + 重新生成 Profile,可能等 24 小时。
* ⇒ 端上改调我们的服务器,服务器用**高德 Web 服务 key** 转发 inputtips。
*
* 为什么这比 Map Kit 更好(不只是绕过 AGC):
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也走高德,
* 两端搜到的 POI 是同一套,不会再出现"网页能搜到、手机搜不到";
* · 高德 inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
* 地址天生完整,不用像 Map Kit 那样拿几个字段自己拼。
*
* ## 频控
*
* 每小时 300 次(按令牌计)。理由:
* · 地址联想是**打字触发**的,一次输入可能查 3~5 次(防抖后);
* · 正常用户一小时输入十几个地点,300 次足够宽松;
* · 高德 Web 服务 key 有日配额,被刷会**影响整个账号**,
* 所以这里必须有闸(宁可偶尔误伤,也不能让配额被打光)。
*
* ⚠️ 超限**不报错**,返回空数组 —— 联想是可选能力,
* 失败时输入框退化成普通文本框,用户手敲照常保存。
*/
private function geo(string $method): void
{
[, $pid] = Api::me();
$q = trim((string)($_GET['q'] ?? $_GET['keyword'] ?? ''));
if ($q === '') {
Api::ok(['tips' => []]);
}
if (mb_strlen($q, 'UTF-8') < 2) {
// 一个字不查(与网页端同一阈值,省配额)
Api::ok(['tips' => []]);
}
if (mb_strlen($q, 'UTF-8') > 40) {
$q = mb_substr($q, 0, 40, 'UTF-8');
}
$limit = (int)($_GET['limit'] ?? 8);
if ($limit <= 0 || $limit > 20) {
$limit = 8;
}
Api::ok(['tips' => \App\Geo::suggest($q, $limit)]);
}
private function notifications(int $id, string $sub, string $method): void
{
[, $pid] = Api::me();
@@ -2463,6 +2525,91 @@ final class ApiHandler
旅程 / 行程
═══════════════════════════════════════════════════════════════ */
/**
* `/api/v1/uploads/<sub>` —— 暂存上传。
*
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,
* 连带着跟其他参数一起提交,后台接口拿到数据,先插入行程,
* 拿到 id,再把图片插入进去。」
*
* 正是这条流程 ⇒ 文件先落暂存区(`App\StagedUpload`),拿 stoken;
* 保存节点时把 stoken 一起提交,`Trip::saveNode` 之后由 `claimStaged()` 挂上去。
*
* ⚠️ 为什么不直接把文件挂在节点上(上一版的做法):
* 新增时节点还没有 id,无处可挂 —— 这是用户指出的核心问题。
*/
private function uploads(string $sub, string $method): void
{
[$fam, $pid] = Api::me();
Api::requireWrite();
if ($sub !== 'stage') {
Api::notFound('这个上传入口');
}
if ($method !== 'POST') {
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
$file = $_FILES['file'] ?? null;
if (!is_array($file)) {
Api::fail('没有收到文件。', 'unprocessable', 422);
}
/* 槽位:行程照片用 'photo'(free 等比,只缩不裁)。
⚠️ 允许调用方指定 `slot`,但只认 ImageSlot 里真实存在的
(StagedUpload::stage 内部会把未知值回落到 'photo')。 */
$slot = Api::str('slot', 'photo');
$res = \App\StagedUpload::stage($fam, $pid, $file, $slot);
if (empty($res['ok'])) {
$m = (string)($res['error'] ?? '');
Api::fail($m !== '' ? $m : '文件没存下来', 'unprocessable', 422);
}
Api::ok([
'stoken' => (string)($res['stoken'] ?? ''),
'name' => (string)($res['name'] ?? ''),
'is_image' => (bool)($res['is_image'] ?? false),
'kind' => (string)($res['kind'] ?? ''),
'size' => (int)($res['size'] ?? 0),
'size_word' => (string)($res['size_word'] ?? ''),
'url' => (string)($res['url'] ?? ''),
], 201);
}
/**
* 把请求体里的 `stage_tokens` 认领到某个行程节点上。
*
* @return array{warns:string[]} 认领过程中的提示(数量不足等)
*/
private function claimStaged(int $fam, int $pid, int $nodeId): array
{
$body = Api::body();
$raw = $body['stage_tokens'] ?? null;
$tokens = [];
if (is_array($raw)) {
foreach ($raw as $t) {
$t = trim((string)$t);
if ($t !== '') {
$tokens[] = $t;
}
}
} elseif (is_string($raw) && trim($raw) !== '') {
/* 也接受逗号串(手搓请求/别的客户端图省事时) */
foreach (explode(',', $raw) as $t) {
$t = trim($t);
if ($t !== '') {
$tokens[] = $t;
}
}
}
if (!$tokens) {
return ['warns' => []];
}
/* ⚠️ `target_type` 必须是 'trip' —— 与网页端 `node_form.php` 的落点一致
(`Attachment::list('trip', $nodeId)` 读的也是它)。
写成 'trip_node' 会导致网页端看不到端上传的照片。 */
$res = \App\StagedUpload::claim($fam, $pid, $tokens, 'trip', $nodeId);
return ['warns' => (array)($res['warns'] ?? [])];
}
private function trips(int $id, string $sub, string $method): void
{
[$fam, $pid] = Api::me();
@@ -2478,9 +2625,183 @@ final class ApiHandler
if ($method === 'POST') {
Api::requireWrite();
$nid = Trip::saveNode($fam, $id, $this->nodeData(null));
Api::ok(Trip::node($nid), 201);
/* 🔴 2026-11-05:**先建节点、拿到 id,再挂暂存的照片**。
用户原话:「先上传,拿到地址,然后提交的时候,连带着跟其他参数
一起提交,后台接口拿到数据,先插入行程,拿到 id,
再把图片插入进去。」
⚠️ 顺序不能反:claim 需要 targetId,节点不存在就无处可挂
(上一版"新增时传不了图"就是这个原因)。
⚠️ 认领失败**不影响节点本身**(节点已建成)—— 只带提示返回,
不把整次保存判失败。 */
$claimRes = $this->claimStaged($fam, $pid, $nid);
$nodeOut = Trip::node($nid) ?? [];
if (!empty($claimRes['warns'])) {
$nodeOut['warns'] = $claimRes['warns'];
}
Api::ok($nodeOut, 201);
}
/* 🔴 2026-11-05 新增:**编辑一段行程**(用户:「鸿蒙端现在行程还不能编辑」)。
为什么之前不能编辑:这里只有 GET / POST,端上无从调用,
只能删了重建(会丢 id 和关联数据:成员、车辆、附件都挂在 node id 上)。
⚠️ 节点 id 从**请求体**读,不放进路径 —— `Api::dispatch()`
只解析三段(`/<res>/<id>/<sub>`),四段路径进不来。
改 dispatch 会波及全部接口,不值得。
⚠️ `nodeData($cur)` 的语义就是"没传的字段沿用旧值"(PATCH 口径),
所以这里传当前行进去,只传想改的字段即可。
⚠️ 必须校验节点属于这趟旅程:否则拿别人的 nodeId 就能改别人的行程
(`Trip::saveNode` 内部按 id UPDATE,不做归属校验)。 */
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$nid = Api::int('id', 0);
if ($nid <= 0) {
Api::fail('要改哪一段行程?没传 id。', 'bad_request', 400);
}
/* 验证节点属于这趟旅程(防越权) */
$chk = Database::get()->prepare(
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
);
$chk->execute([$nid, $id]);
$cur = $chk->fetch();
if (!$cur) {
Api::notFound('这段行程');
}
Trip::saveNode($fam, $id, $this->nodeData($cur), $nid);
/* 同上:编辑时也能把新暂存的照片一起挂上
(用户在表单里加了图、点保存 → 图跟着这次提交一起生效)。 */
$claimRes2 = $this->claimStaged($fam, $pid, $nid);
$nodeOut2 = Trip::node($nid) ?? [];
if (!empty($claimRes2['warns'])) {
$nodeOut2['warns'] = $claimRes2['warns'];
}
Api::ok($nodeOut2);
}
/* 🔴 2026-11-05 新增:**行程节点的照片/附件**。
用户:「它不能这样传图片吗?网页端有什么功能,这个就应该有什么功能。」
网页端 `node_form.php` 的照片落到 `attachments` 表:
target_type = 'trip' target_id = nodeId
⇒ 端上必须用**同一个 target_type**,否则两边各存一份、互相看不见。
⚠️ 路径沿用三段(节点 id 走 query)—— dispatch 只解析三段。 */
if ($sub === 'attach') {
$nid = Api::int('node_id', 0);
if ($nid <= 0) {
Api::fail('要哪一段行程的附件?没传 node_id。', 'bad_request', 400);
}
/* 校验节点属于这趟旅程(防越权) */
$chkA = Database::get()->prepare(
'SELECT id FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
);
$chkA->execute([$nid, $id]);
if (!$chkA->fetch()) {
Api::notFound('这段行程');
}
if ($method === 'GET') {
Api::ok(['items' => \App\Attachment::list('trip', $nid)]);
}
Api::requireWrite();
if ($method === 'POST') {
$file = $_FILES['file'] ?? null;
if (!is_array($file)) {
Api::fail('没有收到文件。', 'unprocessable', 422);
}
$resA = \App\Attachment::save($fam, 'trip', $nid, $file, $pid);
if (empty($resA['ok'])) {
$mA = (string)($resA['error'] ?? '');
/* error 为空 = 用户没选文件(不算错误,但也别装作成功) */
Api::fail($mA !== '' ? $mA : '没选文件', 'unprocessable', 422);
}
Api::ok(['id' => (int)($resA['id'] ?? 0)], 201);
}
if ($method === 'DELETE') {
$aid = Api::int('attachment_id', 0);
if ($aid <= 0) {
Api::fail('要删哪个附件?没传 attachment_id。', 'bad_request', 400);
}
if (!\App\Attachment::removeFrom('trip', $nid, $aid)) {
Api::notFound('这个附件');
}
Api::ok(['deleted' => $aid]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
/* 🔴 #47(2026-11-05):**行程状态切换**。
用户要求状态由"动作"驱动,不再由编辑表单手工选:
· 新建 → 固定 planned
· 点「开始执行」 → ongoing
· 点「已完成」 → done
· 点「已取消」 → cancelled
⚠️ 之前**两端都没有这个动作** —— 状态只能靠编辑表单改,
这正是用户要改掉的。
⚠️ 节点 id 走请求体(dispatch 只解析三段,四段进不来)。
⚠️ 不做状态机校验:允许任意 → 任意,用户改错了要能改回来。
只校验值是否合法。 */
/* 🔴 #48(2026-11-05):**行程动作**(取代 #47 的 status 接口)。
用户要求:状态只属于计划行程,动作驱动流转:
· start → status=ongoing,记录 actual_started_at
· done → **stage 转成 actual**,记录 actual_ended_at
· cancel → stage=skipped(保留数据,不是删除)
⚠️ 为什么替换掉 status 接口:那个允许任意状态互转,
与本单"完成即转实际、实际行程无状态"的模型冲突。
两套并存会让"哪个算数"不可考。
⚠️ 节点 id 走 body(dispatch 只解析三段)。 */
if ($sub === 'action') {
if ($method !== 'PATCH' && $method !== 'POST') {
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
Api::requireWrite();
$nidA = Api::int('node_id', 0);
if ($nidA <= 0) {
Api::fail('要操作哪一段行程?没传 node_id。', 'bad_request', 400);
}
$act = (string)Api::str('action', '');
$resA = Trip::nodeAction($fam, $id, $nidA, $act);
if (empty($resA['ok'])) {
Api::fail((string)($resA['error'] ?? '没成功'), 'unprocessable', 422);
}
Api::ok(Trip::node($nidA));
}
}
/* 🔴 #51(2026-11-05):**节点类型列表**(`GET /trips/<id>/node-types`)。
用户:「这个类型和网页端不一样啊,网页端比这个多得多呀,
我们这上面的类型怎么这么少呢?」
实测库里 9 种(游玩/用餐/赶路/住宿/其他/休息/突发/购物/拜访亲友),
而端上硬编码 5 种 —— 那是照抄了 `Trip::NODE_TYPES` 这个
**兜底常量**,真实数据在 `node_types` 表、用户可增删。
⚠️ 之前 API **完全没有**这个接口,端上只能硬编码。
⚠️ 类型按 **family** 存,与旅程同域,所以挂在 trips 下。
⚠️⚠️ 本段**必须**与 `if ($sub === 'attach')` **同级**。
第一版被我插进了 attach 块内部 ⇒ 请求落到默认分支
返回旅程本体(实测 `data` 里是 id/title/kind…)。
语法检查查不出这种错,只有真打接口才看得出来。 */
if ($sub === 'node-types' && $method === 'GET') {
$rows = \App\NodeType::all($fam);
$out = [];
foreach ($rows as $r) {
$out[] = [
'id' => (int)($r['id'] ?? 0),
'name' => (string)($r['name'] ?? ''),
'icon' => (string)($r['icon'] ?? ''),
'is_system' => (int)($r['is_system'] ?? 0),
];
}
Api::ok(['items' => $out]);
}
if ($sub === 'summary' && $method === 'GET') {
Api::ok(Trip::summary($id, $pid));
}
+225
View File
@@ -474,6 +474,101 @@ final class Geo
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。
*/
/**
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
*
* 🔴 2026-11-05 新增。用户原话:
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
* 这没必要,直接城市就可以。」
*
* ## 为什么必须在服务端做
*
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
* 这是历史行为。靠"前端自觉"不可靠:
* · 老版本 App 还在用;
* · 第三方/脚本调用不经过我们的前端;
* · 老数据已经脏了。
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
*
* ## 取值顺序
*
* ① 已经就是纯市名(「青岛市」)→ 原样返回
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
* ④ 都没有 → 返回空串(**不猜**)
*
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
* @return string 纯市名;认不出返回空串
*/
public static function cityOnly(string $raw): string
{
$s = trim($raw);
if ($s === '') {
return '';
}
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
return $s;
}
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
$dot = mb_strpos($s, '·');
if ($dot !== false) {
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
if ($s !== '') {
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
return $s;
}
if (!preg_match('/[省区县旗盟]/u', $s)) {
return $s . '市';
}
}
}
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
$segs = preg_split('/\s+/u', $s) ?: [];
foreach ($segs as $seg) {
$got = self::firstCityWord($seg);
if ($got !== '') {
return $got;
}
}
/* 整串里找第一个「XX市」 */
$got = self::firstCityWord($s);
if ($got !== '') {
return $got;
}
/* 直辖市不带"市"字(「北京朝阳区」) */
foreach (self::MUNICIPALITIES as $m) {
$short = rtrim($m, '市');
if ($short !== '' && mb_strpos($s, $short) === 0) {
return $m;
}
}
return '';
}
/**
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
*
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
*/
private static function firstCityWord(string $s): string
{
if ($s === '') {
return '';
}
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
return $m[1] . '市';
}
return '';
}
public static function cityFromText(string $text): string
{
$t = trim($text);
@@ -636,6 +731,136 @@ final class Geo
}
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
/**
* 地址联想(高德输入提示)。
*
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
*
* 好处(不只是绕过 AGC):
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
* 地址天生完整,不用像 Map Kit 那样自己拼。
*
* @param string $kw 关键词
* @param int $limit 最多几条
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
*/
public static function suggest(string $kw, int $limit = 8): array
{
$q = trim($kw);
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
return [];
}
$key = self::webKey();
if ($key === '') {
return [];
}
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
. '?key=' . urlencode($key)
. '&keywords=' . urlencode($q)
. '&datatype=all'
. '&citylimit=false';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '' || !is_string($body) || $body === '') {
return [];
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
return [];
}
$out = [];
foreach ($j['tips'] as $t) {
/* ⚠️ 高德的字段类型**不保证是字符串**:
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
直接当字符串用会触发 "Array to string conversion" 警告。
⇒ 统一过一遍 toStr()。 */
$name = self::toStr($t['name'] ?? '');
$dist = self::toStr($t['district'] ?? '');
$addr = self::toStr($t['address'] ?? '');
$loc = self::toStr($t['location'] ?? '');
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
continue;
}
$lng = '';
$lat = '';
if ($loc !== '' && strpos($loc, ',') !== false) {
$seg = explode(',', $loc);
if (count($seg) === 2) {
// 高德给的是 "经度,纬度"
$lng = (string)$seg[0];
$lat = (string)$seg[1];
}
}
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
$detail = $dist;
if ($addr !== '') {
$detail = $detail === '' ? $addr : ($detail . $addr);
}
$out[] = [
'name' => $name,
'detail' => $detail,
'district' => $dist,
'address' => $detail,
'lng' => $lng,
'lat' => $lat,
];
if (count($out) >= $limit) {
break;
}
}
return $out;
}
/**
* 把高德返回的值统一成字符串。
*
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
*/
private static function toStr($v): string
{
if (is_string($v)) {
return trim($v);
}
if (is_int($v) || is_float($v)) {
return (string)$v;
}
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
return '';
}
/** 端上地址联想用的 key(Web 服务类型) */
public static function webKey(): string
{
$a = fb_config('amap_web');
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
}
public static function apiKey(): string
{
$a = fb_config('amap');
+16
View File
@@ -1542,6 +1542,22 @@ SQL,
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
self::addColumn($db, 'trip_nodes', 'revision_n',
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
/* 🔴 #48(2026-11-05):**真实开始 / 结束时间**。
用户:「我计划今天 8:00 到 10:00 开车去公园,但我可能出门晚了,
8:10 或 8:30 才点『开始』按钮。这时候就要记录住 8:30 这个时间,
因为 8:30 会成为实际行程的开始时间。也就是说,
**原来计划的时间和现在真正开始的时间都要记录**。」
⚠️ 不能复用 `starts_at` / `ends_at`(那是**计划**时间),
改掉等于把计划丢了。两者并存才能"计划 8:00–10:00 / 实际 8:30–10:30"。
⚠️ 列名带 `actual_` 前缀,与 `starts_at` 形成清晰对照。 */
self::addColumn($db, 'trip_nodes', 'actual_started_at',
"ADD COLUMN `actual_started_at` DATETIME DEFAULT NULL
COMMENT '真实开始时间(点「开始」那一刻;NULL=还没开始)' AFTER `ends_at`");
self::addColumn($db, 'trip_nodes', 'actual_ended_at',
"ADD COLUMN `actual_ended_at` DATETIME DEFAULT NULL
COMMENT '真实结束时间(点「完成」那一刻;NULL=还没完成)' AFTER `actual_started_at`");
// ② 建修正历史表(老库没有)
self::tryStep('建行程修正历史表', static function () use ($db) {
$db->exec(<<<'SQL'
+215 -10
View File
@@ -904,9 +904,152 @@ final class Trip
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
*/
/**
* 行程动作常量(#48)。
*
* ⚠️ 只做三个动作,**不含"删除"**:删除走既有软删(`deleted_at`),
* 与"取消"是两件完全不同的事 —— 用户明确要求区分:
* · 取消 = 不去了,数据保留,列在「未成行」组
* · 删除 = 彻底不要了,不再显示
*/
public const NODE_ACTIONS = ['start', 'done', 'cancel'];
/**
* 执行一个行程动作(#48 的核心逻辑)。
*
* 用户原话(2026-11-05):
* 「添加计划行程时,刚开始添加的时候,它的状态就是"计划中"。
* 当我开始执行这个计划时…这时候就要记录住 8:30 这个时间,因为 8:30 会
* 成为实际行程的开始时间。也就是说,原来计划的时间和现在真正开始的时间都要记录。
* 一旦开始,这个行程的状态就变成"进行中"。等到走完了…我点"完成"。
* 这时候这个内容或者计划行程就变成一个实际行程。
* **一个实际行程没有再没有状态了,因为实际已经发生完了。**
* 但如果这个计划我没有去做…点击一个按钮,然后它就变成了"取消"或者"删除"…
* **取消和删除是不一样的**。」
*
* ## 三个动作
*
* | action | stage | status | 真实时间 |
* |---|---|---|---|
* | `start` | 不变(plan) | `ongoing` | 写 `actual_started_at` = 此刻 |
* | `done` | **plan → actual** | `done` | 写 `actual_ended_at` = 此刻 |
* | `cancel` | **plan → skipped** | `cancelled` | 不写 |
*
* ⚠️ `done` 会把 stage 改成 `actual` —— 这是"完成即转实际"的落点。
* 转成 actual 之后,界面**不再显示任何状态**(用户:实际行程没状态)。
* ⚠️ `cancel` 改成 `skipped` 而不是软删:数据要保留,
* 最后旅程结束时要能看到"哪些计划没做"。
*
* @param string $action start / done / cancel
* @return array{ok:bool,stage?:string,status?:string,error?:string}
*/
public static function nodeAction(int $familyId, int $eventId, int $nodeId, string $action): array
{
if (!in_array($action, self::NODE_ACTIONS, true)) {
return ['ok' => false, 'error' => '不认识的动作。'];
}
$db = Database::get();
$st = $db->prepare(
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
);
$st->execute([$nodeId, $eventId]);
$cur = $st->fetch();
if (!$cur) {
return ['ok' => false, 'error' => '找不到这段行程。'];
}
/* ⚠️ 已经转成实际行程的,不再接受状态类动作。
用户:「如果这个行程已经转变成实际行程了…那这个行程就不再有其他状态了。」
⇒ `done` 之后点 `start` / `cancel` 都无意义,直接拒绝。 */
$curStage = (string)($cur['stage'] ?? 'plan');
if ($curStage === 'actual' && $action !== 'done') {
return ['ok' => false, 'error' => '这段已经是实际行程了,不再有状态。'];
}
$now = date('Y-m-d H:i:s');
$stage = $curStage;
$status = (string)($cur['status'] ?? 'planned');
if ($action === 'start') {
$status = 'ongoing';
/* 只在**第一次**开始时记录:重复点不该刷新真实开始时间 */
if ((string)($cur['actual_started_at'] ?? '') === '') {
$up = $db->prepare('UPDATE trip_nodes SET actual_started_at = ? WHERE id = ?');
$up->execute([$now, $nodeId]);
}
} elseif ($action === 'done') {
$stage = 'actual';
$status = 'done';
/* 若用户没点过「开始」直接点「完成」,用计划开始时间兜底记实际开始
(否则实际行程缺开始时间,展示上少一半信息) */
if ((string)($cur['actual_started_at'] ?? '') === '') {
$up0 = $db->prepare(
'UPDATE trip_nodes SET actual_started_at = COALESCE(starts_at, ?) WHERE id = ?'
);
$up0->execute([$now, $nodeId]);
}
$up2 = $db->prepare('UPDATE trip_nodes SET actual_ended_at = ? WHERE id = ?');
$up2->execute([$now, $nodeId]);
} else { // cancel
$stage = 'skipped';
$status = 'cancelled';
}
/* 同步 category(老的双轨字段,由 stage 派生,两边不能打架) */
$category = $stage === 'actual' ? 'reality' : 'plan';
$up3 = $db->prepare(
'UPDATE trip_nodes SET stage = ?, status = ?, category = ?, updated_at = NOW()
WHERE id = ?'
);
$up3->execute([$stage, $status, $category, $nodeId]);
return ['ok' => true, 'stage' => $stage, 'status' => $status];
}
/**
* 这段行程该显示哪些动作(#48)。
*
* ⚠️ 前后端**共用同一份规则**,避免"网页端能点、端上不能点"这种不一致。
*
* 规则:
* · `actual` → 空数组(实际行程没有状态类动作,只有编辑/删除)
* · `skipped` → 空数组(已经取消,不再转)
* · `plan` + planned → 开始
* · `plan` + ongoing → 完成
* · `plan` + 其他 → 开始
*
* @return string[] 动作 key 列表
*/
public static function nodeAvailableActions(string $stage, string $status): array
{
if ($stage === 'actual' || $stage === 'skipped') {
/* ⚠️ 实际行程 / 未成行**没有状态动作** ——
用户:「实际行程是没有状态的…实际已经发生完了,它再有状态就没有意义了。」 */
return [];
}
if ($status === 'ongoing') {
return ['done'];
}
return ['start'];
}
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
{
$db = Database::get();
/* 🔴 2026-11-05(#27 事故):**先取出当前行**,供下面"没传就沿用原值"用。
⚠️ 不要用 `$cur ?? []` —— `saveNode()` 这层根本没有 `$cur`
(那是 `ApiHandler::trips()` 的局部变量),
写成 `?? []` 会永远拿到空数组,"保留原值"形同虚设。
CUR_ROW_LOADED */
$curRow = [];
if ($id !== null && $id > 0) {
$sc = $db->prepare('SELECT * FROM trip_nodes WHERE id = ?');
$sc->execute([$id]);
$got = $sc->fetch();
if (is_array($got)) {
$curRow = $got;
}
}
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
@@ -923,18 +1066,54 @@ final class Trip
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
$fromPlace = (string)($d['from_place'] ?? '');
$toPlace = (string)($d['to_place'] ?? '');
$fromAddr = trim((string)($d['from_addr'] ?? ''));
$toAddr = trim((string)($d['to_addr'] ?? ''));
$fromLng = self::coordOrNull($d['from_lng'] ?? null);
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
$toLng = self::coordOrNull($d['to_lng'] ?? null);
$toLat = self::coordOrNull($d['to_lat'] ?? null);
/* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
**库里原有的地点被静默抹掉**。
踩到的调用方:
· 端上编辑浮层(旧版只传 `place`,不传 from_place/to_place)
· 任何脚本/第三方调用
· 我的验证脚本(就是这次事故的直接原因)
⇒ 改成 PATCH 语义:**没传就沿用原值,传了空串才是明确清空**。
这样"用户主动删掉地点"仍然生效(他传空串),
而"调用方不关心这个字段"不会造成破坏。
⚠️ `$cur` 是本方法开头查出来的当前行(编辑时有值,新建时为 null)。
取不到时退回空串 —— 新建本来就该是空。 */
$hasF = static fn(string $k): bool => array_key_exists($k, $d);
$fromPlace = $hasF('from_place')
? (string)$d['from_place'] : (string)($curRow['from_place'] ?? '');
$toPlace = $hasF('to_place')
? (string)$d['to_place'] : (string)($curRow['to_place'] ?? '');
$fromAddr = $hasF('from_addr')
? trim((string)$d['from_addr']) : (string)($curRow['from_addr'] ?? '');
$toAddr = $hasF('to_addr')
? trim((string)$d['to_addr']) : (string)($curRow['to_addr'] ?? '');
$fromLng = $hasF('from_lng')
? self::coordOrNull($d['from_lng']) : self::coordOrNull($curRow['from_lng'] ?? null);
$fromLat = $hasF('from_lat')
? self::coordOrNull($d['from_lat']) : self::coordOrNull($curRow['from_lat'] ?? null);
$toLng = $hasF('to_lng')
? self::coordOrNull($d['to_lng']) : self::coordOrNull($curRow['to_lng'] ?? null);
$toLat = $hasF('to_lat')
? self::coordOrNull($d['to_lat']) : self::coordOrNull($curRow['to_lat'] ?? null);
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
/* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
这没必要,直接城市就可以」)。
前端传来的可能是「山东省青岛市市南区」(高德的 district 整串),
直接存会让旅程的城市聚合变成一长串行政区划。
⇒ 过一道 `Geo::cityOnly()` 剥成纯市名「青岛市」。
⚠️ 不能指望前端改好就完事:老版本 App 还在用、
第三方调用不经过我们的前端、老数据已经脏了。
服务端归一化是**唯一的真防线**。 */
$fromCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'));
$toCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'));
if ($fromCity === '') {
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
}
@@ -1464,6 +1643,32 @@ final class Trip
return self::NODE_TYPES[$key][1] ?? '📌';
}
/**
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
*
* 🔴 2026-11-05(用户:「明明选的是坐火车,但网页版的图标显示的还是一辆汽车」)。
*
* 原来左侧只按 `node_type` 取 ⇒「赶路」永远显示 🚗,
* 哪怕这段实际是坐火车(`travel_mode = 'train'` 应该是 🚄)。
*
* 现在:
* ① 有出行方式 → 用它(火车 🚄 / 飞机 ✈️ / 轮船 🚢 / 自驾 🚗…)
* ② 没有 → 退回节点类型(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
*
* ⚠️ 端上 `fbNodeLeadIcon()` 必须是**同一套优先级**,否则两边图标不一致。
*/
public static function leadIcon(string $nodeType, string $mode, int $familyId = 0): string
{
if ($mode !== '') {
$mi = self::modeIcon($mode);
/* modeIcon 对未知 key 回 '🧭'(不是空串),所以这里判空保底 */
if ($mi !== '') {
return $mi;
}
}
return self::typeIcon($nodeType, $familyId);
}
/** 出行方式的名字 / 图标 */
public static function modeWord(string $key): string
{
+225 -6
View File
@@ -118,6 +118,193 @@
* 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。
* @returns {string} 形如 "青岛市 市南区" / "青岛" / ''(都没有)
*/
/**
* 只取「市 + 区」,**不带省** —— 给输入框 value 用。
*
* 🔴 2026-11-03:用户要求详细地址写成「青岛市黄岛区珠江路1018号」。
* 而 `readPlace()` 返回的是带省的「山东省 青岛市 黄岛区」。
*
* 为什么 value 里**不带省**:
* · 城市名足够定位,省是冗余的(中国没有跨省同名地级市)
* · 输入框宽度有限,带上「山东省」会让后半截(门牌号)显示不全
* · 导航用不到省
*
* 直辖市的坑:`province === city === '北京市'`,只留一份。
*/
/**
* 只取**城市名**(「青岛市」),用于 `from_city` / `to_city` 这类聚合字段。
*
* 🔴 2026-11-05 新增。用户:「城市…直接城市就可以」。
*
* 与 `readPlace()` 的区别:
* · `readPlace()` = 省 + 市 + 区(「山东省青岛市市南区」)→ 给 `place` 显示用
* · `cityOnly()` = 只要市一级(「青岛市」) → 给 city 存库用
*
* 三级取值(高德不同接口给的字段不一样):
* ① `addressComponent.city`(AutoComplete / 逆地理)—— 最准
* ② 直辖市:`addressComponent.city` 是空,用 `province`(「北京市」)
* ③ `poi.district` 整串(inputtips:「山东省青岛市崂山区」)→ 正则剥出市名
*
* ⚠️ 拿不到就返回空串,**不猜**。空着比错着好(服务端还有一层 `Geo::cityOnly` 会补)。
*/
function cityOnly(poi) {
var a = poi.addressComponent || null;
var city = '';
var prov = '';
if (a) {
city = String(a.city || '').trim();
prov = String(a.province || '').trim();
/* 直辖市:`city` 为空(或与 province 相同),本身就是市一级 */
if (!city && prov) {
city = prov;
}
}
if (!city) {
/* inputtips 只给一个 district 整串:`山东省青岛市崂山区` /
`北京市丰台区` / `内蒙古自治区呼和浩特市新城区` */
var d = String(poi.district || '').trim();
city = cityFromDistrict(d);
}
return city;
}
/**
* 从「省市区」整串里剥出**市名**(「山东省青岛市崂山区」→「青岛市」)。
*
* 规则(按可靠度排序):
* ① 取**第一个**「XX市」—— 中文地址是 省→市→区 的层级顺序,
* 第一个「市」就是地级市。
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市;
* 「山东省青岛市市南区」的"市南区"里还有"市"字,靠"第一个"才能对。
* ② 直辖市(不带"市"字):「北京市丰台区」→ 第一个「市」就是「北京市」,
* 走 ① 即可;万一写成「北京朝阳区」才需要这级。
* ③ 都没有 ⇒ 空串。
*/
function cityFromDistrict(d) {
if (!d) { return ''; }
var s = String(d).trim();
/* ① 第一个「XX市」:XX 里不能含"省市县区旗盟"这些层级字眼,
否则「山东省」会被当成「山东省市」 */
var m = s.match(/([^\s省市县区旗盟]{1,10})市/);
if (m) {
return m[1] + '市';
}
/* ② 直辖市:开头就是「北京市」这类,上面已覆盖;
剩下「北京朝阳区」这种去掉末尾区县,取前段 */
var m2 = s.match(/^(北京|上海|天津|重庆)/);
if (m2) {
return m2[1] + '市';
}
return '';
}
function cityDistrictOf(poi) {
var a = poi.addressComponent || null;
var city = '', district = '';
if (a) {
city = String(a.city || a.province || '').trim();
district = String(a.district || '').trim();
}
if (!city && !district) {
city = String(poi.cityname || poi.pname || '').trim();
district = String(poi.adname || '').trim();
}
if (!city && !district) {
// inputtips 只给一个整串:「山东省青岛市崂山区」
// ⇒ 从整串里截出「XX市XX区」部分
var whole = String(poi.district || '').trim();
if (whole !== '') {
var mi = whole.indexOf('市');
if (mi >= 0) {
city = whole.substring(0, mi + 1);
district = whole.substring(mi + 1);
} else {
city = whole;
}
}
}
var parts = [];
if (city) { parts.push(city); }
if (district && district !== city) { parts.push(district); }
return parts.join('');
}
/**
* 把「省市区」与「街道门牌」合并,**缺哪段补哪段**,已有的不重复。
*
* 🔴 2026-11-03:原来这里判断太粗("address 含「区」字就整个不用 cd"),
* 导致 `cd='青岛市黄岛区'` + `address='黄岛区珠江路1018号'`
* 拼成 `黄岛区珠江路1018号` —— **把「青岛市」丢了**。
* 而城市名正是地理编码最关键的线索(丢了就会搜到外省同名路)。
*
* 正确逻辑:逐段检查 `address` 里有没有,没有才补。
*
* cd='青岛市黄岛区', address='珠江路1018号'
* → 两段都没有 ⇒ 全补 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='黄岛区珠江路1018号'
* → 有「黄岛区」、缺「青岛市」⇒ 补城市 ⇒ '青岛市黄岛区珠江路1018号'
* cd='青岛市黄岛区', address='青岛市黄岛区珠江路1018号'
* → 全都有 ⇒ 原样
*
* @param {string} cd cityDistrictOf 的结果(如「青岛市黄岛区」)
* @param {string} addr 高德的 poi.address(如「珠江路1018号」)
* @return {string}
*/
function mergeAddrTail(cd, addr) {
var ad = String(addr || '').trim();
var c = String(cd || '').trim();
if (c === '') { return ad; }
if (ad === '') { return c; }
// 把 cd 拆成「城市」与「区县」两段:找第一个「市」字切开
var mi = c.indexOf('市');
var city = '', dist = '';
if (mi >= 0) {
city = c.substring(0, mi + 1); // 含「市」
dist = c.substring(mi + 1);
} else {
city = c;
}
var needCity = city !== '' && ad.indexOf(city) < 0;
// 区县名可能不带「区/县」后缀出现在 address 里,用去掉后缀的裸名判断
var distBare = dist.replace(/[区县市旗]$/, '');
var needDist = dist !== '' && ad.indexOf(dist) < 0
&& (distBare === '' || ad.indexOf(distBare) < 0);
/* ⚠️ 拼接顺序必须是「城市 → 区县 → 街道」。
测出来的 case:cd='青岛市黄岛区'、addr='青岛市珠江路1018号'
(有城缺区)时,若直接 `prefix + ad` 会得到
「黄岛区青岛市珠江路1018号」—— 区跑到城市前面了。
⇒ 分段拼:先城市,再区,最后街道(街道在 ad 里,
但要先把 ad 里已有的城市/区剥掉避免重复)。 */
var tailAd = ad;
if (!needCity) {
// ad 已含城市 ⇒ 剥掉前缀,后面统一按 城市+区+街道 拼
var idxC = ad.indexOf(city);
if (idxC === 0) {
tailAd = ad.substring(city.length);
} else if (idxC > 0) {
tailAd = ad.substring(0, idxC) + ad.substring(idxC + city.length);
}
}
if (!needDist && dist !== "") {
var idxD = tailAd.indexOf(dist);
if (idxD >= 0) {
tailAd = tailAd.substring(0, idxD) + tailAd.substring(idxD + dist.length);
} else if (distBare !== "") {
var idxB = tailAd.indexOf(distBare);
if (idxB >= 0) {
// 裸名保留(「黄岛珠江路」里的「黄岛」是地址的一部分,不剥)
}
}
}
var prefix = "";
if (city !== "") { prefix += city; }
if (dist !== "") { prefix += dist; }
return prefix + tailAd.trim();
}
function readPlace(poi) {
var a = poi.addressComponent || null;
var province = '', city = '', district = '';
@@ -422,11 +609,31 @@
`poi.name` 塞回输入框(实测,v68 第一版就因此白写)——
同步写会被它覆盖,看起来像"详细地址没生效"。 */
if (detail) {
var ad0 = String(poi.address || '').trim();
if (ad0 !== '' && name !== '') {
var full = name + ' · ' + ad0;
input.value = full;
setTimeout(function () { input.value = full; }, 0);
/* 🔴🔴 2026-11-03 修(用户:「这并不是详细地址,详细地址应该是
青岛市黄岛区珠江路1018号」):
原来拼的是 `name · poi.address`,而高德的 `poi.address`
**只到街道级**(「珠江路1018号」),省市区在别的字段里。
⇒ 存进库里就是「招商银行(...) · 珠江路1018号」,
**缺城市**。鸿蒙端做地理编码时抓不到城市,
全国搜「珠江路1018号」→ 搜到**江苏南通**(差 500 公里)。
现在拼三段:`name · 市+区 + 街道门牌`
→ 「招商银行(...) · 青岛市黄岛区珠江路1018号」
⚠️ 用 `cityDistrictOf()` 而不是 `readPlace()`:
value 里不带省(省冗余、输入框显示不下、导航用不到)。
⚠️ 若高德连 city 都没给(极少数),退回原来的两段拼法,
至少不比改之前差。 */
var ad0 = String(poi.address || '').trim();
var cd = cityDistrictOf(poi);
if (name !== '') {
var tail = mergeAddrTail(cd, ad0);
if (tail !== '') {
var full = name + ' · ' + tail;
input.value = full;
setTimeout(function () { input.value = full; }, 0);
}
}
}
@@ -439,9 +646,21 @@
// 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。
// ⇒ 空着才补;已经有内容就尊重用户的。
if (String(el.value || '').trim() !== '') { return; }
if (t === 'place' || t === 'city') {
/* 🔴 2026-11-05 修(用户:「城市…什么省也带上了,市也带上了,
区甚至都带上了,这没必要,直接城市就可以」)。
`city` 字段**不能用 `readPlace()`** —— 那个函数的用途是
「显示完整地名」(省 + 市 + 区),是给 `place` 输入框用的。
拿它填 city 会存进库里变成「山东省青岛市市南区」,
导致旅程的城市聚合统计里出现一长串行政区划。
⇒ `city` 单独走 `cityOnly()`:只取「青岛市」这一级。 */
if (t === 'place') {
var pl = readPlace(poi);
if (pl !== '') { el.value = pl; }
} else if (t === 'city') {
var co = cityOnly(poi);
if (co !== '') { el.value = co; }
} else if (t === 'address' || t === 'addr') {
var ad = String(poi.address || '').trim();
if (ad !== '') { el.value = ad; }
+151
View File
@@ -2645,3 +2645,154 @@ window.fbHomeTaskDrop = function (row, wasStatus) {
}
return true;
};
/* ============================================================
#47(2026-11-05)行程状态动作按钮:点一下 → PATCH → 就地重绘
------------------------------------------------------------
用户要求状态由**动作**驱动,不再由编辑表单手工选。
服务端接口:PATCH /api/v1/trips/<tripId>/nodes/status {node_id, status}
⚠️ 但网页端走的是表单式站点,不是 API 客户端 —— 用一个轻量的
POST 到同源端点(下面用 fetch 打站点自己的 node_status 动作)。
============================================================ */
(function () {
function bindNodeStatus() {
var btns = document.querySelectorAll('[data-node-status-set]');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
self.setAttribute('data-busy', '1');
var nodeId = self.getAttribute('data-node');
var status = self.getAttribute('data-node-status-set');
var box = self.closest('[data-node-status]');
var tripId = document.body.getAttribute('data-trip-id') || '';
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('status', status);
if (tripId) { fd.append('trip_id', tripId); }
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_status', {
method: 'POST',
body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '状态没改成,请重试。');
return;
}
/* 就地刷新这一段的动作条(不整页重载,避免丢失滚动位置) */
if (box && res.html) {
box.outerHTML = res.html;
bindNodeStatus();
} else {
location.reload();
}
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,状态没改成。');
});
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeStatus);
} else {
bindNodeStatus();
}
window.fbBindNodeStatus = bindNodeStatus;
})();
/* ============================================================
#48(2026-11-05)行程动作:开始 / 完成 / 取消 / 删除
------------------------------------------------------------
用户:「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是开始,一个是完成,一个是编辑,一个是删除。
在删除前面还应该有一个按钮,这个按钮是取消。」
⚠️ 完成 = 转成实际行程(stage: plan → actual),之后不再有状态。
⚠️ 取消 ≠ 删除:取消保留数据(stage=skipped),删除才软删。
============================================================ */
(function () {
function bindNodeActs() {
/* ---- 状态动作(开始 / 完成 / 取消)---- */
var acts = document.querySelectorAll('[data-node-act]');
for (var i = 0; i < acts.length; i++) {
var b = acts[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
var act = self.getAttribute('data-node-act');
var nodeId = self.getAttribute('data-node');
/* 取消是不可逆的语义(虽然数据还在),先确认一下 */
if (act === 'cancel' && !confirm('标记为不去了?数据会保留在「未成行」里。')) {
return;
}
self.setAttribute('data-busy', '1');
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('act', act);
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_act', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '没成功,请重试。');
return;
}
/* 动作会改变分组(计划→实际/未成行),必须整页重载 */
location.reload();
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,没成功。');
});
});
}
/* ---- 删除(软删)---- */
var dels = document.querySelectorAll('[data-node-del]');
for (var k = 0; k < dels.length; k++) {
var d = dels[k];
if (d.getAttribute('data-bound') === '1') { continue; }
d.setAttribute('data-bound', '1');
d.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (!confirm('彻底删掉这段行程?')) { return; }
var fd = new FormData();
fd.append('node_id', self.getAttribute('data-node-del'));
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_del', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '删不掉,请重试。');
return;
}
location.reload();
}).catch(function () { alert('网络出错。'); });
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeActs);
} else {
bindNodeActs();
}
window.fbBindNodeActs = bindNodeActs;
})();
+110
View File
@@ -4521,6 +4521,116 @@ switch ($r) {
redirect('trips/node_edit', ['id' => $nodeId]);
break;
/* 🔴 #47(2026-11-05):**行程状态切换**(网页端)。
用户:「在计划行程里面点击开始执行,它的状态自然变成进行中…
如果它没去,取消了,那就自然变成已取消。」
⇒ 状态由动作驱动,不再由编辑表单手工选(表单那边已删掉「执行状态」)。
⚠️ 返回 **JSON**(不是 redirect):前端 `app.js` 拿到后**只重绘那一段**
的动作条,不整页刷新 —— 否则用户刚点完就被弹回顶部。
⚠️ 返回的 `html` 是该节点动作条的新 HTML,前端用 outerHTML 就地替换。 */
/* 🔴 #48(2026-11-05):**行程动作**(开始 / 完成 / 取消)。
用户:「计划行程右上角…一个是开始,一个是完成,一个是编辑,一个是删除。
在删除前面还应该有一个按钮,这个按钮是取消。」
⇒ 返回 JSON,前端据此决定是否重载(动作会改变分组)。 */
case 'node_act':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
$act = (string)($_POST['act'] ?? '');
header('Content-Type: application/json; charset=utf-8');
if ($nodeId <= 0) {
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
exit;
}
/* 校验归属(nodes → events 查一遍) */
$stA = Database::get()->prepare(
'SELECT n.event_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 = ? AND n.deleted_at IS NULL'
);
$stA->execute([$nodeId, $familyId, $familyId]);
$evId = $stA->fetchColumn();
if ($evId === false) {
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
exit;
}
$resA = \App\Trip::nodeAction($familyId, (int)$evId, $nodeId, $act);
if (empty($resA['ok'])) {
echo json_encode(['ok' => false, 'error' => (string)($resA['error'] ?? '没成功')], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => true, 'stage' => $resA['stage'] ?? '', 'status' => $resA['status'] ?? ''], JSON_UNESCAPED_UNICODE);
exit;
/* 🔴 #48:**删除**(软删,与"取消"完全不同)。
用户:「所谓的删除,指的是我直接把这个计划给删掉了。」
⇒ `deleted_at` 置位,彻底不显示。 */
case 'node_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
header('Content-Type: application/json; charset=utf-8');
if ($nodeId <= 0) {
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
exit;
}
$stD = 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 = ? AND n.deleted_at IS NULL'
);
$stD->execute([$nodeId, $familyId, $familyId]);
if ($stD->fetchColumn() === false) {
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
exit;
}
$upD = Database::get()->prepare(
'UPDATE trip_nodes SET deleted_at = NOW(), updated_at = NOW() WHERE id = ?'
);
$upD->execute([$nodeId]);
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
exit;
case 'node_status':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
$newSt = (string)($_POST['status'] ?? '');
if ($nodeId <= 0) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!array_key_exists($newSt, \App\Trip::NODE_STATUS)) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => '状态值不对'], JSON_UNESCAPED_UNICODE);
exit;
}
/* 校验归属(顺着 nodes → events 查一遍,防越权) */
$stN = 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 = ? AND n.deleted_at IS NULL'
);
$stN->execute([$nodeId, $familyId, $familyId]);
if ($stN->fetchColumn() === false) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
exit;
}
$upN = Database::get()->prepare(
'UPDATE trip_nodes SET status = ?, updated_at = NOW() WHERE id = ?'
);
$upN->execute([$newSt, $nodeId]);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'status' => $newSt,
'status_word' => \App\Trip::NODE_STATUS[$newSt],
], JSON_UNESCAPED_UNICODE);
exit;
case 'trips/expense_del':
$familyId = (int)Auth::familyId();
Auth::requireWrite();
+13 -17
View File
@@ -171,13 +171,12 @@ $nfEmbed = !empty($nfEmbed);
</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 /* 🔴 #47(2026-11-05):**「当前状态」不再显示**。
用户:「实际行程、当前状态实际行程,这个地方也不要出现,
因为这是没有意义的。我现在本来就是添加的一个实际行程。」
⇒ 阶段由入口决定(计划/实际两颗按钮),编辑时不该再展示。 */ ?>
<?php /* ---- 标题 ---- */ ?>
<div class="field full">
@@ -202,7 +201,6 @@ $nfEmbed = !empty($nfEmbed);
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
</div>
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
@@ -251,7 +249,6 @@ $nfEmbed = !empty($nfEmbed);
<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">
<span class="tip">座位这类同团不同座的信息就不记了。</span>
</div>
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
原话:「添加行程,线路名、承运方,都不要了。」
@@ -283,16 +280,15 @@ $nfEmbed = !empty($nfEmbed);
</div>
</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>
<?php /* 🔴 #47(2026-11-05):**「执行状态」不再由表单选**。
用户:「这个里面不要让它手动去动。只要是添加的计划行程,
它的状态就是计划中。在计划行程里面点击开始执行,
它的状态自然变成进行中…所以编辑这个地方不要出现
计划中、进行中、已完成、已取消。」
⇒ 状态由 show.php 节点卡片上的动作按钮改(#47 新增的 API)。
表单**不提交 status**,服务端 saveNode 的 PATCH 语义会沿用原值
(见 Trip::saveNode 里 `array_key_exists` 的判定)。 */ ?>
<div class="field full">
<label for="nf-detail">记录</label>
+84 -12
View File
@@ -36,10 +36,14 @@ foreach ((array)($nodes ?? []) as $n) {
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
$bysStage[$sg][] = $n;
}
/* 🔴 #48(2026-11-05):**提示语删掉**(用户:「已走过 —— 也出现在日历与首页
『往年今日』,这句话删除」)。不只删这一句 —— 三个分组的提示语一律去掉:
组头只留名称和条数,界面更干净。
⚠️ 保留数组结构(只用 [0] 名称),改动面最小。 */
$groupMeta = [
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
'plan' => ['计划的行程'],
'actual' => ['实际行程'],
'skipped' => ['未成行'],
];
?>
<div class="page-head">
@@ -228,14 +232,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php endif; ?>
</div>
<?php else: ?>
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?>
<?php /* 🔴 #48:**实际行程放最前** —— 用户:「到最后整个旅程结束之后,
我们最终看到的是实际行程。」⇒ actual → plan → skipped(未成行沉底)。 */ ?>
<?php foreach (['actual', 'plan', '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($groupMeta[$sg][0]) ?>
<span class="group-n"><?= count($list) ?> 段</span>
</h2>
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
<ol class="node-list">
<?php foreach ($list as $n): ?>
@@ -250,8 +255,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
?>
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
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)) ?>
<?php /* 🔴 2026-11-05(用户:「明明选的是坐火车,网页版的图标显示的还是一辆汽车」):
左侧图标改成**出行方式优先** —— 坐火车就显 🚄,
不再一律显节点类型的 🚗。与端上 `fbNodeLeadIcon()` 同一逻辑。 */ ?>
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)
. ($mode !== '' ? ' · ' . \App\Trip::modeWord($mode) : '')) ?>">
<?= e(\App\Trip::leadIcon((string)$n['node_type'], $mode, $fid)) ?>
</span>
<div class="node-body">
<div class="node-head">
@@ -285,8 +294,22 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
<?php if ($span !== '日期待补'): ?>
<div class="node-time">
<?= e($span) ?>
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
<?php /* 🔴 #48:**计划时间与实际时间同时显示**。
用户:「原来计划的时间和现在真正开始的时间都要记录。」
⇒ 计划用常规色,实际用主题色并标注「实际」。 */ ?>
<?php if ($sg === 'actual'): ?>
<span class="t-plan">计划 <?= e($span) ?></span>
<?php
$aStart = (string)($n['actual_started_at'] ?? '');
$aEnd = (string)($n['actual_ended_at'] ?? '');
if ($aStart !== ''):
$aSpan = \App\Trip::nodeSpan($aStart, $aEnd !== '' ? $aEnd : null);
?>
<span class="t-actual">实际 <?= e($aSpan) ?></span>
<?php endif; ?>
<?php else: ?>
<?= e($span) ?>
<?php endif; ?>
</div>
<?php endif; ?>
@@ -316,23 +339,72 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
?>
<?php /* 🔴 2026-11-05(用户:「直接用个标签,然后右边一个箭头,
到北京南站就得了,不用写从哪到哪」):
两个地名都做成**胶囊标签**,箭头只作分隔。
有坐标的套 <a>(可点开导航),没坐标的套 span ——
**两种情况都用同一个类**,样式才一致(原来无链接时
是裸文本,和另一侧的胶囊对不齐,这正是"位都没对齐"的观感来源)。 */ ?>
<?php if ($navFrom !== ''): ?>
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
<?php else: ?>
<?= e((string)($n['from_place'] ?? '—')) ?>
<span class="route-stop"><?= e((string)($n['from_place'] ?? '—')) ?></span>
<?php endif; ?>
<span class="arrow">→</span>
<?php if ($navTo !== ''): ?>
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
<?php else: ?>
<?= e((string)($n['to_place'] ?? '—')) ?>
<span class="route-stop"><?= e((string)($n['to_place'] ?? '—')) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($n['detail'])): ?>
<!-- 🔴🔴 #48(2026-11-05)行程动作按钮组。
用户原话:
「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是"开始",一个是"完成",一个是"编辑",一个是"删除"。
在删除前面还应该有一个按钮,这个按钮是取消,
指的就是不去了,但这个事情我并不把它删掉。」
⇒ 计划行程:开始 / 完成 / 编辑 / 取消 / 删除
⇒ 实际行程:编辑 / 删除(无状态、无开始无完成)
⇒ 未成行: 编辑 / 删除(已取消,不再转)
⚠️ 当前状态用实心 chip 表示,与端上 pillBtn(selected) 同一视觉语言。
⚠️ 按钮规则来自 Trip::nodeAvailableActions() —— 前后端共用一份。
⚠️ 这里用 HTML 注释而不是 PHP 注释:PHP 的 /* */ 注释里遇到 `?>`
会提前结束 PHP 段(词法层面 `?>` 优先于注释),导致解析错误。 -->
<?php
$stageNow = (string)($n['stage'] ?? 'plan');
$statusNow = (string)($n['status'] ?? 'planned');
$acts = \App\Trip::nodeAvailableActions($stageNow, $statusNow);
$isActual = $stageNow === 'actual';
?>
<div class="node-actions">
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<span class="node-act is-on"><?= e(\App\Trip::NODE_STATUS[$statusNow] ?? '计划中') ?></span>
<?php endif; ?>
<?php foreach ($acts as $a): ?>
<?php
$label = $a === 'start' ? '开始' : '完成';
$cls = $a === 'start' ? 'go' : 'ok';
?>
<button type="button" class="node-act node-act-<?= e($cls) ?>"
data-node-act="<?= e($a) ?>"
data-node="<?= e($nid) ?>"><?= e($label) ?></button>
<?php endforeach; ?>
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
<button type="button" class="node-act node-act-warn"
data-node-act="cancel"
data-node="<?= e($nid) ?>"
title="不去了,但数据保留">取消</button>
<?php endif; ?>
</div>
<?php if (!empty($n['detail'])): ?>
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
<?php endif; ?>