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