v66 续:旅程三项设置(车辆管理 / 出行方式 / 节点类型)

用户 2026-10-20:「旅程的……车辆管理、出行方式、节点设置,这些东西都还没完成。」
这三项的 `action` 一直是**空串**(点了没反应),是本次批量需求里最后一块。

三项的真实能力并不一样,按真实情况做,不摆假入口:
· **车辆管理**:`vehicles` 表 + `Vehicle::save/destroy` 早就有,缺的只是 API
  ⇒ 新增 `GET/POST/PATCH/DELETE /api/v1/vehicles`;端上列表 + 内嵌表单
  (车型 / 车牌 / 自家·租的·借的 / 车主或租车公司 / 订单号 / 默认车 / 备注)
· **节点类型**:`node_types` 表 + `NodeType::save/destroy` 同理
  ⇒ 新增 `GET/POST/PATCH/DELETE /api/v1/nodetypes`;改名会**级联**到已有行程
  (`trip_nodes.node_type` 存的是名字,服务端 `NodeType::save` 里做)
  · 内置类型**不画删除按钮**(服务端会拒,画了就是"点了没反应")
· **出行方式**:**枚举常量**(`Trip::TRAVEL_MODES`),没有"管理"这回事
  ⇒ 只做一份只读对照表 + "加行程时选",**不做可编辑表单**
  (反向验证里有一条专门锁"这个浮层里不许出现保存/删除")

几个实现上的要点:
· 两条新路由是**平级资源**(`/vehicles`、`/nodetypes`),不是 `trips/xxx` 子路由 ——
  `Api::dispatch()` 只解析三段,写成四段会落到「trips 的第 0 条」= 列表
· 删不掉(还有 N 段行程在用 / 内置类型)回 **422 + 服务端原话**,不是 500:
  那是"现在不能删",不是"服务器出错"
· 表单输入框**不按值传 value**:`@Builder` 的值参数不参与状态观察,
  而这个浮层是"列表 + 内嵌表单"(点另一行的「编辑」时表单已经渲染着)
  ⇒ 会显示上一辆车的内容。改成传 key、builder 内部直接读 `this.fXxx`
· 三个浮层都遵守浮层铁律(根 `Stack` 叠放 + `hitTestBehavior(Transparent)`),
  各挂一个 `@Monitor` 让底部悬浮栏让位;`backOnce()` 按层级顺序关

服务端(`app/ApiHandler.php`)已随 `familybook_push.sh` 部署,线上核对:
`/api/v1/ping` 的 features 里 `trips_vehicles` / `trips_nodetypes` 均为 1,
两个端点无令牌时回 **401 unauthorized**(说明路由存在,而不是 404 not_found)。

验证:`hvigorw assembleHap` BUILD SUCCESSFUL;静态守卫 769 条全绿;
反向验证 116 条注入全部能让守卫变红(含"字段名拼错""按值传 value"等假绿陷阱)。
⚠️ 设备仍断连,真机未验收。
This commit is contained in:
2026-10-20 16:36:30 +08:00
parent b3e1330244
commit 58f5509c1c
5 changed files with 1198 additions and 5 deletions
@@ -0,0 +1,219 @@
/**
* 家书 FamilyBook · 旅程三项设置的**写操作**(鸿蒙端)
*
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
* 这些东西都还没完成。」
*
* 三件事的实际情况并不一样,这里按真实情况办:
* · **车辆管理** → `vehicles` 表 + `Vehicle::save/destroy`,端上补增删改;
* · **节点类型** → `node_types` 表 + `NodeType::save/destroy`,端上补增删改;
* · **出行方式** → **枚举常量**(`Trip::TRAVEL_MODES`),根本没有"管理"这回事
* ⇒ 只做一份只读说明 + 提示"在行程里选",**不摆假入口**。
*
* 服务端契约(v66 新增,与网页版 `trips/vehicles.php` / `trips/types.php` 同一套模型):
* GET /api/v1/vehicles 车辆列表(含 used_count)
* POST /api/v1/vehicles 新增
* PATCH /api/v1/vehicles/<id> 改
* DELETE /api/v1/vehicles/<id> 删
* GET /api/v1/nodetypes 节点类型列表
* POST /api/v1/nodetypes 新增
* PATCH /api/v1/nodetypes/<id> 改(**改名会级联到已有行程**,见服务端注释)
* DELETE /api/v1/nodetypes/<id> 删(内置的 / 还在用的会被拒)
*
* ⚠️ 删不掉的两种情况**不是错误**,是服务端的保护:
* · 车辆:"还有 N 段行程在用这辆车"
* · 类型:内置类型 / 还有 N 段行程在用
* ⇒ 端上把 `error` 原文显示出来即可(服务端那句话已经是人话)。
*/
import { fbApi, FbApiResult } from './FbHttp';
import { FbNodeTypeItem, FbVehicleItem } from '../model/Types';
/** 新建/修改车辆时提交的字段(与网页版 `vehicles.php` 的表单对齐) */
export interface FbVehicleDraft {
plate: string;
name: string;
/** `own` / `rental` / `borrowed` */
vtype: string;
vendor: string;
orderNo: string;
isDefault: boolean;
note: string;
}
/** 新建/修改节点类型时提交的字段 */
export interface FbNodeTypeDraft {
name: string;
icon: string;
}
/** 字符串读值(服务端可能给 null) */
function s(v: Object | null | undefined): string {
return typeof v === 'string' ? v : '';
}
/** 数字读值 */
function n(v: Object | null | undefined): number {
return typeof v === 'number' ? v : 0;
}
/** 车辆列表 → 端上类型 */
export function parseVehicles(json: string): FbVehicleItem[] {
let rows: Object[] = [];
try {
const v: Object = JSON.parse(json) as Object;
if (Array.isArray(v)) {
rows = v as Object[];
}
} catch (err) {
return [];
}
const out: FbVehicleItem[] = [];
for (const r of rows) {
const o: Record<string, Object> = r as Record<string, Object>;
const it: FbVehicleItem = {
id: n(o['id'] as number),
plate: s(o['plate'] as string),
name: s(o['name'] as string),
vtype: s(o['vtype'] as string) !== '' ? s(o['vtype'] as string) : 'own',
vendor: s(o['vendor'] as string),
orderNo: s(o['order_no'] as string),
isDefault: n(o['is_default'] as number) > 0,
note: s(o['note'] as string),
// ⚠️ 服务端不一定给 `used_count`(老服务端没有这条接口时会 404,
// 这里拿不到就按 0 处理;删的时候仍由服务端把关)
usedCount: n(o['used_count'] as number)
};
out.push(it);
}
return out;
}
/** 节点类型列表 → 端上类型 */
export function parseNodeTypes(json: string): FbNodeTypeItem[] {
let rows: Object[] = [];
try {
const v: Object = JSON.parse(json) as Object;
if (Array.isArray(v)) {
rows = v as Object[];
}
} catch (err) {
return [];
}
const out: FbNodeTypeItem[] = [];
for (const r of rows) {
const o: Record<string, Object> = r as Record<string, Object>;
const it: FbNodeTypeItem = {
id: n(o['id'] as number),
name: s(o['name'] as string),
icon: s(o['icon'] as string),
isSystem: n(o['is_system'] as number) > 0,
usedCount: n(o['used_count'] as number)
};
if (it.name !== '') {
out.push(it);
}
}
return out;
}
/** 取车辆列表 */
export async function fetchVehicles(token: string): Promise<FbVehicleItem[]> {
const r: FbApiResult = await fbApi('/api/v1/vehicles', { token: token });
if (!r.ok) {
return [];
}
return parseVehicles(r.data);
}
/** 新增 / 修改一辆车(`id > 0` = 改) */
export async function saveVehicle(token: string, d: FbVehicleDraft,
id: number = 0): Promise<FbApiResult> {
/* ⚠️ 请求体一律 `Record<string, Object> = {}` 再逐键赋值 ——
ArkTS 的 `arkts-no-untyped-obj-literals` 不认 Record 字面量
(与 `FbTripRepo.createTrip` 同一写法)。 */
const body: Record<string, Object> = {};
body['plate'] = d.plate;
body['name'] = d.name;
body['vtype'] = d.vtype;
body['vendor'] = d.vendor;
body['order_no'] = d.orderNo;
body['is_default'] = d.isDefault ? 1 : 0;
body['note'] = d.note;
const path: string = id > 0 ? '/api/v1/vehicles/' + id.toString() : '/api/v1/vehicles';
return await fbApi(path, {
method: id > 0 ? 'PATCH' : 'POST',
token: token,
body: body
});
}
/** 删一辆车(服务端会拦"还有行程在用") */
export async function deleteVehicle(token: string, id: number): Promise<FbApiResult> {
return await fbApi('/api/v1/vehicles/' + id.toString(), { method: 'DELETE', token: token });
}
/** 取节点类型列表 */
export async function fetchNodeTypes(token: string): Promise<FbNodeTypeItem[]> {
const r: FbApiResult = await fbApi('/api/v1/nodetypes', { token: token });
if (!r.ok) {
return [];
}
return parseNodeTypes(r.data);
}
/** 新增 / 修改一种节点类型(`id > 0` = 改;改名会级联到已有行程) */
export async function saveNodeType(token: string, d: FbNodeTypeDraft,
id: number = 0): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['name'] = d.name;
body['icon'] = d.icon;
const path: string = id > 0 ? '/api/v1/nodetypes/' + id.toString() : '/api/v1/nodetypes';
return await fbApi(path, {
method: id > 0 ? 'PATCH' : 'POST',
token: token,
body: body
});
}
/** 删一种节点类型(内置 / 还在用的会被拒) */
export async function deleteNodeType(token: string, id: number): Promise<FbApiResult> {
return await fbApi('/api/v1/nodetypes/' + id.toString(), { method: 'DELETE', token: token });
}
/**
* 出行方式(**只读枚举**,与 `app/Trip.php` 的 `TRAVEL_MODES` **同一套 key**)。
*
* ⚠️ 它不是一张表,没有增删改 —— 「出行方式设置」这项在端上就是一份说明:
* 列出这十种、并告诉用户"在加行程时选"。
* 🔴 不要照"车辆管理"的样子给它做一个能新增的表单:服务端根本没这回事,
* 做出来只是又一个"点了没用"的入口。
*/
export interface FbTravelModeDef {
key: string;
name: string;
}
export const FB_TRAVEL_MODES: FbTravelModeDef[] = [
{ key: 'walk', name: '步行' },
{ key: 'drive', name: '自驾' },
{ key: 'bus', name: '公交' },
{ key: 'subway', name: '地铁' },
{ key: 'train', name: '火车' },
{ key: 'plane', name: '飞机' },
{ key: 'ship', name: '轮船' },
{ key: 'taxi', name: '打车' },
{ key: 'bike', name: '骑行' },
{ key: 'other', name: '其他' }
];
/** 车辆来源的中文名(`Vehicle::TYPES` 的镜像) */
export function fbVehicleTypeName(vtype: string): string {
if (vtype === 'rental') {
return '租的';
}
if (vtype === 'borrowed') {
return '借的';
}
return '自家的';
}