From f58a12881631c14cbb30ff51bfe9dbd8e833c73a Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 5 Nov 2026 12:40:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=8C=E7=A8=8B=EF=BC=9A#47~#51=20=E6=89=93?= =?UTF-8?q?=E5=8C=85=20=E2=80=94=E2=80=94=20=E5=8F=8C=E8=BD=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E3=80=81=E5=8A=A8=E6=80=81=E7=B1=BB=E5=9E=8B=E3=80=81?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=8C=89=E9=92=AE=E3=80=81=E5=8E=BB=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊 #48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间 #50 类型点击无反应(@Builder 值传参不建立依赖) #51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调 API 新增:GET /trips//node-types、PATCH /trips//nodes/action DB 新增:trip_nodes.actual_started_at / actual_ended_at --- app/ApiHandler.php | 323 +++++++++++++++++++++++++++++++++- app/Geo.php | 225 +++++++++++++++++++++++ app/Schema.php | 16 ++ app/Trip.php | 225 +++++++++++++++++++++-- assets/js/addr_suggest.js | 231 +++++++++++++++++++++++- assets/js/app.js | 151 ++++++++++++++++ index.php | 110 ++++++++++++ templates/trips/node_form.php | 30 ++-- templates/trips/show.php | 96 ++++++++-- 9 files changed, 1361 insertions(+), 46 deletions(-) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 088e0ac..7ad6478 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -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//stage` 会和 + "某趟旅程的某个子动作"语义混淆;而暂存文件是**与宿主无关**的。 + ⚠️ 只能上传,不能列举/删除 —— 暂存区是内部中间态,不暴露管理接口。 */ + case 'uploads': $this->uploads($sub, $method); return; /* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。 ⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 —— `Api::dispatch()` 只解析三段(`///`), @@ -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/` —— 暂存上传。 + * + * 🔴 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()` + 只解析三段(`///`),四段路径进不来。 + 改 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//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)); } diff --git a/app/Geo.php b/app/Geo.php index 1d76d5e..6f65ff4 100644 --- a/app/Geo.php +++ b/app/Geo.php @@ -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 + * **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程") + */ + 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'); diff --git a/app/Schema.php b/app/Schema.php index e649a4c..323bc26 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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' diff --git a/app/Trip.php b/app/Trip.php index 16f7c2b..f2ce4c6 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -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 { diff --git a/assets/js/addr_suggest.js b/assets/js/addr_suggest.js index 2c2ba10..1fd9d58 100644 --- a/assets/js/addr_suggest.js +++ b/assets/js/addr_suggest.js @@ -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; } diff --git a/assets/js/app.js b/assets/js/app.js index 4a7e3c8..d70c144 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2645,3 +2645,154 @@ window.fbHomeTaskDrop = function (row, wasStatus) { } return true; }; + + +/* ============================================================ + #47(2026-11-05)行程状态动作按钮:点一下 → PATCH → 就地重绘 + ------------------------------------------------------------ + 用户要求状态由**动作**驱动,不再由编辑表单手工选。 + 服务端接口:PATCH /api/v1/trips//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; +})(); diff --git a/index.php b/index.php index 98d955e..dbfac71 100644 --- a/index.php +++ b/index.php @@ -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(); diff --git a/templates/trips/node_form.php b/templates/trips/node_form.php index 07eff9a..159b912 100644 --- a/templates/trips/node_form.php +++ b/templates/trips/node_form.php @@ -171,13 +171,12 @@ $nfEmbed = !empty($nfEmbed); - 在右上角「设置 → 节点类型」里可以增删自定义。 -
- -
-
+
@@ -202,7 +201,6 @@ $nfEmbed = !empty($nfEmbed); - 结束时间可不填;填到几点几分,详情页就显示到几点几分。
@@ -251,7 +249,6 @@ $nfEmbed = !empty($nfEmbed); - 座位这类同团不同座的信息就不记了。 -
- - -
+
diff --git a/templates/trips/show.php b/templates/trips/show.php index 80ce84e..cb59297 100644 --- a/templates/trips/show.php +++ b/templates/trips/show.php @@ -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' => ['未成行'], ]; ?>
@@ -228,14 +232,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
- + +

段

-

    @@ -250,8 +255,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); } ?>
  1. - - + + +
    @@ -285,8 +294,22 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
    - - 实际 + + + 计划 + + 实际 + + + +
    @@ -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'] ?? '')); ?> + (可点开导航),没坐标的套 span —— + **两种情况都用同一个类**,样式才一致(原来无链接时 + 是裸文本,和另一侧的胶囊对不齐,这正是"位都没对齐"的观感来源)。 */ ?> - + → - +
    - + + +
    + + + + + + + + + + +
    + +