行程:#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));
}