v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)
用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
都有青岛作为出发地或者目的地,那么就只记一次」
→ trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
→ NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
→ seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
+「给参与人标注角色这个功能去掉」
→ 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
→ 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
→ Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
两头都没时分就不写 00:00)。
其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。
验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
This commit is contained in:
+325
@@ -331,4 +331,329 @@ final class Geo
|
||||
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
|
||||
'台湾省',
|
||||
];
|
||||
|
||||
// ================================================================
|
||||
// 以下为 v24 第 17 批新增:**地址 → 城市**(用于旅程的城市聚合统计)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 高德 Web 服务的逆地理端点 / 超时。
|
||||
*
|
||||
* ⚠️ 与上面天气那套(离线表)是**两条独立路径**,互不影响:
|
||||
* 天气要"城市 → 坐标"(离线表够用),
|
||||
* 旅程要"坐标/地址 → 城市"(必须联网问高德,因为地址千奇百怪)。
|
||||
*/
|
||||
private const REGEO_URL = 'https://restapi.amap.com/v3/geocode/regeo';
|
||||
private const HTTP_TIMEOUT = 3;
|
||||
|
||||
/** 直辖市(高德对它们的 `city` 字段返回空数组,要认 `province`) */
|
||||
private const MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市'];
|
||||
|
||||
/**
|
||||
* 从「经纬度 + 地址文本」解析**城市名**(如「青岛市」)。
|
||||
*
|
||||
* 三级回落,每一级都可用(用户第 1 条要的就是"城市",不能因为接口挂了就统计不出来):
|
||||
* ① **高德逆地理**(有坐标时):`addressComponent.city` 是权威答案;
|
||||
* ② 离线城市表 `CITIES` 做**地址包含匹配**(「黑龙江省伊春市…」里
|
||||
* 能认出已知的城市名;不联网、不受接口抖动影响);
|
||||
* ③ 从地址里提「XX市」(最朴素的一条,但中文地址里几乎总有)。
|
||||
*
|
||||
* ⚠️ 三级都拿不到就返回**空串**,不猜:`place` 里填的是
|
||||
* 「溪水国家森林公园」「青岛胶东国际机场」这类**地点名**,
|
||||
* 从里面猜城市只会猜错(用户宁可少一个城市,也不要一个假城市名)。
|
||||
*
|
||||
* ⚠️ 绝不抛异常:这是"锦上添花",它挂了不能让保存行程失败。
|
||||
*/
|
||||
public static function cityFromPoint($lng, $lat, string $fallback = ''): string
|
||||
{
|
||||
if (is_numeric($lng) && is_numeric($lat)) {
|
||||
$ac = self::regeo((string)$lng, (string)$lat);
|
||||
if ($ac !== null) {
|
||||
$c = self::cityOfComponents($ac);
|
||||
if ($c !== '') {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return self::cityFromText($fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调高德逆地理,返回 `regeocode.addressComponent`(失败返回 null)。
|
||||
*
|
||||
* ⚠️ 用 cURL 而不是 `file_get_contents`:后者在 `allow_url_fopen=0`
|
||||
* 的主机上不可用(宝塔默认可能就是关的),而且**没有超时控制**
|
||||
* —— 高德慢一点,用户保存行程就会一直转圈。
|
||||
* ⚠️ 成败看 body 里的 `status`(字符串 "1"),**不是** HTTP 状态码:
|
||||
* 配额用完时高德返回 200 + status="0",只看 HTTP 会误判成功。
|
||||
*/
|
||||
public static function regeo(string $lng, string $lat): ?array
|
||||
{
|
||||
$key = self::apiKey();
|
||||
if ($key === '' || !function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$url = self::REGEO_URL . '?' . http_build_query([
|
||||
'key' => $key,
|
||||
'location' => $lng . ',' . $lat, // 高德要「经度,纬度」
|
||||
'extensions' => 'base',
|
||||
'output' => 'JSON',
|
||||
]);
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::HTTP_TIMEOUT,
|
||||
CURLOPT_CONNECTTIMEOUT => self::HTTP_TIMEOUT,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_USERAGENT => 'FamilyBook/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$err = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
if ($err !== 0 || !is_string($body) || $body === '') {
|
||||
return null;
|
||||
}
|
||||
$j = json_decode($body, true);
|
||||
if (!is_array($j) || (string)($j['status'] ?? '') !== '1') {
|
||||
return null;
|
||||
}
|
||||
$ac = $j['regeocode']['addressComponent'] ?? null;
|
||||
return is_array($ac) ? $ac : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从高德的 addressComponent 里取出"城市"。
|
||||
*
|
||||
* ⚠️ 直辖市(北京/上海/天津/重庆)的 `city` 字段是**空数组或空串**,
|
||||
* 它们本身就是城市一级。只读 `city` 会让这四地的行程一个城市都统计不出来
|
||||
* (用户会觉得"北京那次怎么不算")。
|
||||
*/
|
||||
public static function cityOfComponents(array $ac): string
|
||||
{
|
||||
$pick = static function ($v): string {
|
||||
// ⚠️ 高德在没有值时返回**空数组**(不是空串),is_array 这层不能省
|
||||
if (is_array($v)) {
|
||||
$v = $v['name'] ?? '';
|
||||
}
|
||||
return trim((string)$v);
|
||||
};
|
||||
$city = $pick($ac['city'] ?? '');
|
||||
$prov = $pick($ac['province'] ?? '');
|
||||
if ($city !== '') {
|
||||
return $city;
|
||||
}
|
||||
foreach (self::MUNICIPALITIES as $m) {
|
||||
if ($prov === $m) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**地址文本**里提城市(不联网;②③ 两级都走这里)。
|
||||
*
|
||||
* 顺序(可靠 → 不可靠):
|
||||
* ① 先提「XX市」:中文地址里城市几乎总以「XX市」出现,
|
||||
* 而且地址是 **省 → 市 → 区** 的层级顺序,所以取**第一个**「XX市」
|
||||
* 就是地级市那级。
|
||||
* ⚠️ 不能取"最后一个":「黑龙江省黑河市五大连池市五大连池镇」取最后
|
||||
* 会得到县级市「五大连池市」,再往前回溯窗口一截就成「大连市」了
|
||||
* (实测踩到);而且「山东省青岛市市南区」的"市南区"里还有个"市"字。
|
||||
* ② 提不出来时才查离线表 `CITIES` 做**包含匹配**(最长优先)。
|
||||
* ⚠️ 只在 ① 失败时用:「长春市朝阳区」包含「朝阳」(辽宁朝阳市在表里),
|
||||
* 先走离线表会返回「朝阳市」—— 离了大谱(实测踩到)。
|
||||
* 「青岛胶东国际机场」这种**没有"市"字的地点名**才是这一级要救的。
|
||||
* ③ 直辖市(不带"市"字也可能写:「北京朝阳区」)。
|
||||
*
|
||||
* ⚠️ 不猜地点名:`place` 字段里全是「溪水国家森林公园」这种,
|
||||
* 从里面猜城市只会猜错(宁可不计,不要算错)。
|
||||
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
|
||||
* 不是靠猜。
|
||||
*/
|
||||
public static function cityFromText(string $text): string
|
||||
{
|
||||
$t = trim($text);
|
||||
if ($t === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---- ⓪ 直辖市**先认**(必须在剥省份前缀之前)----
|
||||
// ⚠️ 顺序错了会出大错(实测踩到):「北京市朝阳区建国路」如果先剥掉
|
||||
// 「北京市」(它在 PROVINCE_WORDS 里),剩下「朝阳区建国路」,
|
||||
// 接着在离线表里匹配到「朝阳」→ 返回**辽宁朝阳市**。离了大谱。
|
||||
// 所以直辖市要在最前面单独认掉。
|
||||
foreach (self::MUNICIPALITIES as $m) {
|
||||
if (mb_strpos($t, $m, 0, 'UTF-8') !== false) {
|
||||
return $m;
|
||||
}
|
||||
// 也可能是短名(「北京朝阳区」)
|
||||
if (mb_strpos($t, mb_substr($m, 0, 2, 'UTF-8'), 0, 'UTF-8') !== false) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ① 提「XX市」----
|
||||
// ⚠️ 三步的顺序与量词都很讲究,每一条都是实测踩出来的:
|
||||
//
|
||||
// a) **先去省份前缀**。不去的话「内蒙古自治区呼和浩特市」用惰性匹配
|
||||
// 最多看 7 个字,`{2,7}?` 够不到「呼和浩特市」(省名占了 6 个字)
|
||||
// → 提不出城市。
|
||||
// b) **惰性量词 `{2,7}?`**,不是贪婪。贪婪会先试最长的,
|
||||
// 「山东省青岛市市南区」会匹配到 `青岛市市`(把市南区的"市"当成后缀)
|
||||
// → 得出「青岛市市」。惰性从最短开始试,先命中「青岛市」。
|
||||
// c) **取第一个命中的**。地址是"省 → 市 → 区"的层级顺序,
|
||||
// 第一个「XX市」就是地级市那级。
|
||||
// ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市五大连池镇」取最后
|
||||
// 会拿到县级市「五大连池市」,再回溯窗口一截就成了「大连市」。
|
||||
$body = $t;
|
||||
foreach (self::PROVINCE_WORDS as $pw) {
|
||||
if ($pw !== '' && mb_strpos($body, $pw, 0, 'UTF-8') === 0) {
|
||||
$body = (string)mb_substr($body, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 排除"市场 / 市民 / 市区"这类:它们后面那个字会暴露自己
|
||||
$badNext = ['场', '民', '集', '政', '长', '面', '内', '外', '郊', '容'];
|
||||
if (preg_match('/([\x{4e00}-\x{9fa5}]{2,7}?)市([\x{4e00}-\x{9fa5}])?/u',
|
||||
$body, $m)) {
|
||||
$cand = $m[1];
|
||||
$nxt = (string)($m[2] ?? '');
|
||||
if (!($nxt !== '' && in_array($nxt, $badNext, true))) {
|
||||
// 兜一层:万一身体里还残留「省」(「XX省XX市」连着写、没被上面剥掉)
|
||||
$sp = mb_strpos($cand, '省', 0, 'UTF-8');
|
||||
if ($sp !== false) {
|
||||
$cand = (string)mb_substr($cand, $sp + 1, null, 'UTF-8');
|
||||
}
|
||||
if (mb_strlen($cand, 'UTF-8') >= 2) {
|
||||
return $cand . '市';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ② 离线表包含匹配(最长优先)----
|
||||
// 只有 ① 提不出来才走这里(见上面的说明:先走会被「朝阳」这类误命中)。
|
||||
//
|
||||
// 🔴 这一级**很危险**(实盘 dry-run 抓到):「五大连池风景区-龙门石寨景区」
|
||||
// 里包含「大连」二字 → 被判成**辽宁大连市**(离了大谱)。
|
||||
// 收紧两条:
|
||||
// a) 匹配到的城市名**前面那一个字不能是汉字**(字符串开头除外)——
|
||||
// 「五大连池」的"大连"前面是"五",被排除;「青岛胶东国际机场」的
|
||||
// "青岛"前面是开头,保留。
|
||||
// b) 匹配位置**后面**那一个字也不能是"字"这类能拼成新词的
|
||||
// (「张家口」的"张家"后面是"口")—— 其实 a) 已够,b) 作补充。
|
||||
// 老实说这一级只该救「XX机场」「XX站」这种**没有市字的地点名**,
|
||||
// 真正的地址(带省市)都会在 ① 就被提出来。
|
||||
$best = '';
|
||||
$bestPos = -1;
|
||||
foreach (self::CITIES as $name => $_xy) {
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
// 直辖市在表里存的是短名,这一级不处理(交给 ③,且已经在前面的 ⓪ 认过了)
|
||||
$isMuni = false;
|
||||
foreach (self::MUNICIPALITIES as $mu) {
|
||||
if (mb_substr($mu, 0, 2, 'UTF-8') === $name) { $isMuni = true; break; }
|
||||
}
|
||||
if ($isMuni) {
|
||||
continue;
|
||||
}
|
||||
$p = mb_strpos($t, $name, 0, 'UTF-8');
|
||||
if ($p === false) {
|
||||
continue;
|
||||
}
|
||||
// (a) 前面那个字若是汉字 → 说明这个"城市名"是更长地名的一部分,跳过
|
||||
if ($p > 0) {
|
||||
$prev = mb_substr($t, $p - 1, 1, 'UTF-8');
|
||||
if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $prev)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 最长优先(并列时取靠前的)
|
||||
if (mb_strlen($name, 'UTF-8') > mb_strlen($best, 'UTF-8')) {
|
||||
$best = $name;
|
||||
$bestPos = $p;
|
||||
}
|
||||
}
|
||||
if ($best !== '') {
|
||||
return $best . '市';
|
||||
}
|
||||
|
||||
// ---- ③ 直辖市(不带"市")----
|
||||
foreach (self::MUNICIPALITIES as $m) {
|
||||
$short = mb_substr($m, 0, 2, 'UTF-8');
|
||||
if (mb_strpos($t, $short, 0, 'UTF-8') !== false) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段行程的**城市集合**(出发地 + 到达地,去重)。
|
||||
*
|
||||
* 用户第 1 条:「有多段行程,都有青岛作为出发地或者目的地,那么就只记一次」
|
||||
* —— 正是这里:出发地与到达地**都算**,然后去重。
|
||||
*
|
||||
* @param array $n trip_nodes 行(读 from_city / to_city)
|
||||
* @return string[] 城市名(出发地在前)
|
||||
*/
|
||||
public static function nodeCities(array $n): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['from_city', 'to_city'] as $k) {
|
||||
$c = trim((string)($n[$k] ?? ''));
|
||||
if ($c !== '' && !in_array($c, $out, true)) {
|
||||
$out[] = $c;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 高德导航链接(TripLog 同款;用户第 3 条要"记录详细地址"就是为了能导航过去)。
|
||||
*
|
||||
* ⚠️ 有坐标走 `navigation`(精确到点),没坐标只能走 `search`(按名字搜)。
|
||||
*/
|
||||
public static function navUrl($lng, $lat, string $name = '', string $mode = 'car'): string
|
||||
{
|
||||
$a = fb_config('amap');
|
||||
$src = (is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '') ?: 'family.example.com';
|
||||
if (is_numeric($lng) && is_numeric($lat)) {
|
||||
$to = (string)$lng . ',' . (string)$lat . ($name !== '' ? ',' . rawurlencode($name) : '');
|
||||
return 'https://uri.amap.com/navigation?to=' . $to
|
||||
. '&mode=' . rawurlencode($mode)
|
||||
. '&policy=1&src=' . rawurlencode($src) . '&coordinate=gaode&callnative=1';
|
||||
}
|
||||
$kw = trim($name);
|
||||
if ($kw === '') {
|
||||
return '';
|
||||
}
|
||||
return 'https://uri.amap.com/search?keyword=' . rawurlencode($kw) . '&src=' . rawurlencode($src);
|
||||
}
|
||||
|
||||
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
|
||||
public static function apiKey(): string
|
||||
{
|
||||
$a = fb_config('amap');
|
||||
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端地址联想需要的配置(**JS API** key + 安全密钥)。
|
||||
*
|
||||
* @return array{key:string, security:string, nav_src:string}
|
||||
*/
|
||||
public static function jsConfig(): array
|
||||
{
|
||||
$a = fb_config('amap');
|
||||
return [
|
||||
'key' => is_array($a) ? trim((string)($a['key'] ?? '')) : '',
|
||||
'security' => is_array($a) ? trim((string)($a['security_js'] ?? '')) : '',
|
||||
'nav_src' => is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+79
-2
@@ -31,6 +31,29 @@ final class NodeType
|
||||
'other' => ['其他', '📌'],
|
||||
];
|
||||
|
||||
/**
|
||||
* TripLog(trip.yangyq.net)实际用过的类型 —— 用户第 17 批 第 2 条要求
|
||||
* 「你看看 triplog 里面都有哪些,都拿过来」。
|
||||
*
|
||||
* ⚠️ 这份清单是**照着线上库抄的**(7 种,见 Schema 里那句注释)。
|
||||
* 与 BUILTIN 的差别就两种:
|
||||
* · 这里**没有「住宿」** —— 本地有,保留(家庭出游住宿是刚需,
|
||||
* TripLog 当时用「休息」兼职了这件事,不代表我们也要那样);
|
||||
* · 这里**多「休息/突发/购物/拜访亲友」** —— 本地要补上。
|
||||
* 图标也照 TripLog 抄(🍽️ 😴 ⚠️ 🛍️ 🏠),保持两边认得出是同一类。
|
||||
*
|
||||
* @var array<string,string> 名字 => 图标
|
||||
*/
|
||||
public const TRIPLOG_TYPES = [
|
||||
'赶路' => '🚗',
|
||||
'游玩' => '🎡',
|
||||
'用餐' => '🍽️',
|
||||
'休息' => '😴',
|
||||
'突发' => '⚠️',
|
||||
'购物' => '🛍️',
|
||||
'拜访亲友' => '🏠',
|
||||
];
|
||||
|
||||
/**
|
||||
* 一个家的全部类型(表里没有就先把内置的预置进去)。
|
||||
*
|
||||
@@ -41,6 +64,13 @@ final class NodeType
|
||||
public static function all(int $familyId): array
|
||||
{
|
||||
self::seedDefaults($familyId);
|
||||
// ⚠️ TripLog 那几种(休息/突发/购物/拜访亲友)也要在**读时**自愈地补。
|
||||
// 只放在升级步骤里不够:升级是**一次性**的(靠 Schema 指纹触发),
|
||||
// 而"重装/清库后重建"不会重跑升级 —— 那时表里只有 seedDefaults 的 5 种,
|
||||
// 用户第 17 批 第 2 条要的那 4 种永远进不来(e2e 实测就是这个症状:
|
||||
// 库重建后类型只剩「游玩,用餐,赶路,住宿,其他,赶海」)。
|
||||
// seedFromTripLog 内部是 INSERT IGNORE,重复调用零成本。
|
||||
self::seedFromTripLog($familyId);
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT id, name, icon, is_system, position FROM node_types
|
||||
WHERE family_id = ? ORDER BY position, id'
|
||||
@@ -69,8 +99,7 @@ final class NodeType
|
||||
if ($has >= count(self::BUILTIN)) {
|
||||
return 0; // 已经有内置那几条了,不必逐个查(热点路径)
|
||||
}
|
||||
$hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
|
||||
. (int)$familyId)->fetchColumn();
|
||||
$hasAny = $n; // 上面已经查过一次了,直接用
|
||||
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
|
||||
// 每次读都补回去会让人永远删不掉系统内置的那几种。
|
||||
if ($hasAny > 0) {
|
||||
@@ -89,6 +118,54 @@ final class NodeType
|
||||
return $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 补齐 **TripLog 实际用过的**那一批类型(v24 第 17 批 用户第 2 条)。
|
||||
*
|
||||
* 用户原话:「添加行程那里,类型不够丰富,你看看 triplog 里面都有哪些,都拿过来。」
|
||||
*
|
||||
* TripLog 线上(trip.yangyq.net)实际有 7 种,其中 5 种是它自己的内置、
|
||||
* 2 种是本人后来自己加的(`is_system=0`):
|
||||
* 赶路🚗 / 游玩🎡 / 用餐🍽️ / 休息😴 / 突发⚠️ / 购物🛍️ / 拜访亲友🏠
|
||||
*
|
||||
* 与本地原有 5 种(游玩/用餐/赶路/住宿/其他)相比:
|
||||
* · 差的是 **休息、突发、购物、拜访亲友**(本地没有);
|
||||
* · 「住宿」是本地有而 TripLog 没有的 —— **保留**(用户没说要删,而且
|
||||
* 家庭出游住宿是刚需,TripLog 当时是用「休息」兼职了这件事)。
|
||||
*
|
||||
* ⚠️ 与 `seedDefaults` 的关键区别:那个只在**表全空**时预置(保护用户的删除),
|
||||
* 这个要**按名字逐个补**(只补缺的那几个)—— 因为用户明确要求"都拿过来",
|
||||
* 而线上库已经有 5 条了,不补的话差的 4 种永远进不来。
|
||||
* ⚠️ 用 `INSERT IGNORE`(靠 uk_nt_family_name 唯一键)而不是先查后插:
|
||||
* 并发/重跑都安全,少一轮 N+1 查询。
|
||||
* ⚠️ 标 `is_system=1`:这几个是"系统预置"(与 TripLog 同源),不该被删掉
|
||||
* —— 它们在 TripLog 那边也是内置的。
|
||||
*
|
||||
* @return int 补了几条
|
||||
*/
|
||||
public static function seedFromTripLog(int $familyId): int
|
||||
{
|
||||
$db = Database::get();
|
||||
try {
|
||||
// 表还没建(极老的库,升级跑到这一步之前)→ 静默返回,别把页面炸掉
|
||||
$db->query('SELECT 1 FROM node_types LIMIT 1');
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
$st = $db->prepare(
|
||||
'INSERT IGNORE INTO node_types (family_id, name, icon, is_system, position)
|
||||
VALUES (?,?,?,1,?)'
|
||||
);
|
||||
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0) FROM node_types WHERE family_id = '
|
||||
. (int)$familyId)->fetchColumn());
|
||||
$n = 0;
|
||||
foreach (self::TRIPLOG_TYPES as $name => $icon) {
|
||||
$pos += 10;
|
||||
$st->execute([$familyId, $name, $icon, $pos]);
|
||||
$n += $st->rowCount();
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
public static function find(int $familyId, int $id): ?array
|
||||
{
|
||||
$st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
|
||||
|
||||
+57
-1
@@ -73,6 +73,15 @@ final class Schema
|
||||
* (实际起止 / 参与人 / 备注),可无限次,供日后对比。
|
||||
* 迁移:category='reality' → stage='actual','plan' → stage='plan'
|
||||
* (按现有轨道自动迁,用户已确认)。
|
||||
* 22 → **城市的聚合与地址(用户 2026-10-05 第 17 批)**:
|
||||
* 用户第 1 条:「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,
|
||||
* 比如说有多段行程,都有青岛作为出发地或者目的地,那么就只记一次。」
|
||||
* → 只靠 `trip_nodes.place` 一个自由文本字段是算不出"城市"的
|
||||
* (那里填的是「青岛胶东国际机场」「溪水国家森林公园」这类**地点**)。
|
||||
* 给 trip_nodes 补两列 `from_city` / `to_city`(出发地/到达地所属城市),
|
||||
* 在**保存行程时**由 `App\Geo` 解析好存下来,读的时候直接去重。
|
||||
* 为什么不每次读时现算:解析要调高德接口(有配额、有网络),
|
||||
* 而城市是"写一次看很多次"的字段 —— 现算会让列表页变慢且可能超配额。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -80,7 +89,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '21';
|
||||
public const VERSION = '22';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -1106,6 +1115,53 @@ SQL,
|
||||
}
|
||||
});
|
||||
|
||||
// ---- v24 第 17 批:城市的聚合与地址(Schema v22)----
|
||||
// 用户第 1 条要的是**城市**("经过了几个城市"),而 `place` 里填的是
|
||||
// 「青岛胶东国际机场」这种**地点名**,直接拿它去重会得到一堆机场/车站名。
|
||||
// 用户第 4 条又点明「图片右侧显示的是聚合的城市,而不是终点地址」——
|
||||
// 所以必须在**出发地 / 到达地**上各解析出一个城市,去重后再展示。
|
||||
//
|
||||
// ⚠️ 解析结果**落库**而不是每次读时现算:
|
||||
// · 解析要调高德逆地理接口(有配额、可能失败),
|
||||
// · 而城市是"写一次、看很多次"的字段(列表页、日历、首页都要用),
|
||||
// 现算会让每次列表刷新都打好几次接口 —— 既慢又可能把配额烧光。
|
||||
// 高德拿不到时 `App\Geo` 会从地址文本里提「XX市」兜底,实在提不出就留空
|
||||
// (留空只是这个城市不计入,不影响页面)。
|
||||
self::addColumn($db, 'trip_nodes', 'from_city',
|
||||
"ADD COLUMN `from_city` VARCHAR(60) DEFAULT NULL COMMENT '出发地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `to_place`");
|
||||
self::addColumn($db, 'trip_nodes', 'to_city',
|
||||
"ADD COLUMN `to_city` VARCHAR(60) DEFAULT NULL COMMENT '到达地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `from_city`");
|
||||
// 地址的**详细地址**也一并存下来(TripLog 同款):用户第 3 条要
|
||||
// 「出发地和到达地,都用高德地图的接口,记录详细地址」——
|
||||
// 光有城市不够,导航要用的是详细地址(或坐标)。
|
||||
self::addColumn($db, 'trip_nodes', 'from_addr',
|
||||
"ADD COLUMN `from_addr` VARCHAR(300) DEFAULT NULL COMMENT '出发地详细地址(高德返回)' AFTER `to_city`");
|
||||
self::addColumn($db, 'trip_nodes', 'to_addr',
|
||||
"ADD COLUMN `to_addr` VARCHAR(300) DEFAULT NULL COMMENT '到达地详细地址(高德返回)' AFTER `from_addr`");
|
||||
self::addColumn($db, 'trip_nodes', 'from_lng',
|
||||
"ADD COLUMN `from_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地经度(高德返回,用于导航链接)' AFTER `to_addr`");
|
||||
self::addColumn($db, 'trip_nodes', 'from_lat',
|
||||
"ADD COLUMN `from_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地纬度' AFTER `from_lng`");
|
||||
self::addColumn($db, 'trip_nodes', 'to_lng',
|
||||
"ADD COLUMN `to_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地经度' AFTER `from_lat`");
|
||||
self::addColumn($db, 'trip_nodes', 'to_lat',
|
||||
"ADD COLUMN `to_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地纬度' AFTER `to_lng`");
|
||||
// ⚠️ `seat_info` / `gate` **保留列、不再使用**(用户第 3 条:
|
||||
// 「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
|
||||
// 因为可能是多个人同行,座位什么的不记录了」)。
|
||||
// 不删列的理由:删列不可逆,而 TripLog 迁来的老数据里这两个字段
|
||||
// 是有真实内容的(本人当时逐条填过);留着它们在库里至少可查。
|
||||
// 界面上彻底不再出现 —— 见 node_form.php / show.php。
|
||||
|
||||
// 类型扩充(用户第 2 条:「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」)。
|
||||
// TripLog 线上实际有 7 种(含两个自定义的),本地只有 5 种内置,差的就是这些。
|
||||
// ⚠️ 只**补**不删:已有的(用户可能改过名)一律保留。
|
||||
self::tryStep('补齐 TripLog 那几种节点类型', static function () use ($db) {
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\NodeType::seedFromTripLog((int)$fam['id']);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
|
||||
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
|
||||
self::addColumn($db, 'trip_nodes', 'stage',
|
||||
|
||||
+230
-38
@@ -64,8 +64,32 @@ final class Trip
|
||||
'other' => ['其他', '🧭'],
|
||||
];
|
||||
|
||||
/** 自驾类(这两种才需要选车辆) */
|
||||
public const DRIVE_MODES = ['drive', 'taxi'];
|
||||
/**
|
||||
* 自驾类(**只有自驾**才需要选车辆)。
|
||||
*
|
||||
* ⚠️ v24 第 17 批 用户第 3 条把范围收窄了:
|
||||
* 原话「出行方式,只有选择**自驾**,才让选车辆」。
|
||||
* 上一批是按 TripLog 的 `DRIVE_MODES` 把「打车」也算进来
|
||||
* (打车时"坐的是哪辆车"确实可填),但用户明确不要 ——
|
||||
* 打车是"叫一辆车",车牌没有记忆价值,选了反而逼人去车库里挑一台不相干的车。
|
||||
*/
|
||||
public const DRIVE_MODES = ['drive'];
|
||||
|
||||
/**
|
||||
* 只需要填「车次 / 航班号」的出行方式(用户第 17 批 第 3 条)。
|
||||
*
|
||||
* 用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
|
||||
* 因为可能是多个人同行,座位什么的不记录了。」
|
||||
*
|
||||
* ⚠️ 于是这两个方式下**不再出现**「座位 / 车厢」「登机口 / 站台」
|
||||
* —— 这两个字段对"一家人出行"毫无意义(一次订五六张票,座位号不在一起,
|
||||
* 而登机口在出发当天才知道)。表单与列表都彻底不显示它们。
|
||||
* `trip_nodes.seat_info` / `gate` 两列**保留不删**(老数据不丢,见 Schema)。
|
||||
*
|
||||
* ⚠️ 名单外的出行方式(公交/地铁/轮船等)连车次都不用填:
|
||||
* 地铁没有"车次"可言,轮船的船名填在标题里就够。
|
||||
*/
|
||||
public const TICKET_MODES = ['train', 'plane'];
|
||||
|
||||
/**
|
||||
* 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。
|
||||
@@ -155,31 +179,54 @@ final class Trip
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。
|
||||
* 一条旅程去过的**城市**(用户第 17 批 第 1/4 条)。
|
||||
*
|
||||
* ⚠️ 顺序按时间排而不是按字母/插入顺序:
|
||||
* "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。
|
||||
* ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在
|
||||
* "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。
|
||||
* 用户原话:
|
||||
* · 第 1 条:「统计出来的不是目的地,也不是车站,而是**城市**…
|
||||
* 城市要聚合,比如说有多段行程,都有青岛作为出发地或者目的地,
|
||||
* 那么就只记一次。」
|
||||
* · 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,而不是终点地址。」
|
||||
*
|
||||
* 做法:把每条行程的 `from_city`(出发地城市)与 `to_city`(到达地城市)
|
||||
* 都算进来,**去重** —— 出发地与到达地各算一票,同一城市只记一次。
|
||||
*
|
||||
* ⚠️ 这两个列是**保存行程时**由 `App\Geo` 解析好写进去的,不是读时现算:
|
||||
* 解析要打高德接口(有配额、可能超时),而城市是"写一次看很多次"的字段。
|
||||
* 老数据由补迁脚本回填(见 `_fb_b17_migrate.php`)。
|
||||
* ⚠️ 两个列都空时**不回落**去读 `place`:`place` 里是「溪水国家森林公园」
|
||||
* 这种**地点名**(用户明确说"不是目的地,也不是车站"),
|
||||
* 拿它当城市正是这一批要改掉的东西。宁可暂时少一个城市,也不要显示错的。
|
||||
* ⚠️ 排序按**最早出现的时间**:先后顺序本身是信息
|
||||
* ("青岛 → 伊春 → 黑河"能看出路线,字母序看不出任何东西)。
|
||||
* 同一时刻(老数据没时间的)用城市名兜底,保证顺序稳定可复现。
|
||||
* ⚠️ skipped(未成行)**不算去过**:计划去但没去成的地方不该出现在
|
||||
* "去过哪几个城市"里(与"不上日程、不进往年今日"是同一条口径)。
|
||||
*/
|
||||
public static function cities(int $eventId): array
|
||||
{
|
||||
// ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报
|
||||
// `Reference 'first_at' not supported (reference to group function)` ——
|
||||
// `Reference 'first_at' not supported (reference to group function)`,
|
||||
// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。
|
||||
// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT n.place, MIN(n.starts_at) AS first_at
|
||||
FROM trip_nodes n
|
||||
WHERE n.event_id = ? AND n.stage IN ('plan','actual')
|
||||
AND n.place IS NOT NULL AND n.place <> ''
|
||||
GROUP BY n.place
|
||||
ORDER BY MIN(n.starts_at), n.place"
|
||||
);
|
||||
$st->execute([$eventId]);
|
||||
// 写成 `ORDER BY MIN(t), city` 就没问题(聚合函数本身可用)。
|
||||
$sql = "SELECT c.city AS city, MIN(c.t) AS first_at, MIN(c.role) AS first_role
|
||||
FROM (
|
||||
SELECT from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role
|
||||
FROM trip_nodes
|
||||
WHERE event_id = ? AND stage IN ('plan','actual')
|
||||
AND from_city IS NOT NULL AND from_city <> ''
|
||||
UNION ALL
|
||||
SELECT to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role
|
||||
FROM trip_nodes
|
||||
WHERE event_id = ? AND stage IN ('plan','actual')
|
||||
AND to_city IS NOT NULL AND to_city <> ''
|
||||
) c
|
||||
GROUP BY c.city
|
||||
ORDER BY MIN(c.t), MIN(c.role), c.city";
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute([$eventId, $eventId]);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$p = trim((string)$r['place']);
|
||||
$p = trim((string)$r['city']);
|
||||
if ($p !== '' && !in_array($p, $out, true)) {
|
||||
$out[] = mb_substr($p, 0, 60, 'UTF-8');
|
||||
}
|
||||
@@ -235,19 +282,29 @@ final class Trip
|
||||
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
// ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名
|
||||
// ⚠️ 同 cities():只认 from_city / to_city(第 17 批第 1 条的城市聚合),
|
||||
// ORDER BY 只写聚合函数、不写别名。原来读的是 place —— 那是**地点名**
|
||||
// (「溪水国家森林公园」),拿它当城市正是这批要改掉的。
|
||||
$st2 = Database::get()->prepare(
|
||||
"SELECT event_id, place, MIN(starts_at) AS first_at
|
||||
"SELECT c.event_id, c.city, MIN(c.t) AS first_at, MIN(c.role) AS first_role
|
||||
FROM (
|
||||
SELECT event_id, from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role
|
||||
FROM trip_nodes
|
||||
WHERE event_id IN ($in) AND stage IN ('plan','actual')
|
||||
AND place IS NOT NULL AND place <> ''
|
||||
GROUP BY event_id, place
|
||||
ORDER BY event_id, MIN(starts_at), place"
|
||||
AND from_city IS NOT NULL AND from_city <> ''
|
||||
UNION ALL
|
||||
SELECT event_id, to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role
|
||||
FROM trip_nodes
|
||||
WHERE event_id IN ($in) AND stage IN ('plan','actual')
|
||||
AND to_city IS NOT NULL AND to_city <> ''
|
||||
) c
|
||||
GROUP BY c.event_id, c.city
|
||||
ORDER BY c.event_id, MIN(c.t), MIN(c.role), c.city"
|
||||
);
|
||||
$st2->execute($ids);
|
||||
$st2->execute(array_merge($ids, $ids));
|
||||
foreach ($st2->fetchAll() as $r) {
|
||||
$id = (int)$r['event_id'];
|
||||
$p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8');
|
||||
$p = mb_substr(trim((string)$r['city']), 0, 60, 'UTF-8');
|
||||
if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) {
|
||||
$city[$id][] = $p;
|
||||
}
|
||||
@@ -641,6 +698,31 @@ final class Trip
|
||||
if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) {
|
||||
$vid = 0;
|
||||
}
|
||||
// ---- v24 第 17 批:出发地 / 到达地的**详细地址 + 坐标 + 城市** ----
|
||||
// 用户第 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);
|
||||
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
|
||||
// 没有才去解析。解析绝不抛异常(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');
|
||||
if ($fromCity === '') {
|
||||
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
||||
}
|
||||
if ($toCity === '') {
|
||||
$toCity = \App\Geo::cityFromPoint($toLng, $toLat, $toPlace !== '' ? $toPlace : $toAddr);
|
||||
}
|
||||
// 详细地址为空时用地点名兜底(用户看到的是"出发地"那行字,不能空着)
|
||||
if ($fromAddr === '') { $fromAddr = $fromPlace; }
|
||||
if ($toAddr === '') { $toAddr = $toPlace; }
|
||||
$fields = [
|
||||
self::textOrNull2($d['title'] ?? ''),
|
||||
self::textOrNull($d['detail'] ?? ''),
|
||||
@@ -658,31 +740,70 @@ final class Trip
|
||||
self::textOrNull2($d['line_name'] ?? ''),
|
||||
self::textOrNull2($d['carrier'] ?? ''),
|
||||
self::textOrNull($d['trip_no'] ?? ''),
|
||||
// ⚠️ 第 17 批:这两个字段**不再从表单接收**(用户第 3 条要求去掉)。
|
||||
// 但编辑老行程时如果把它们写成 null,TripLog 迁来的座位信息就永远消失了
|
||||
// —— 所以这里"保持原值不变"(下面对 UPDATE 单独处理这两列)。
|
||||
self::textOrNull($d['seat_info'] ?? ''),
|
||||
self::textOrNull($d['gate'] ?? ''),
|
||||
self::textOrNull2($d['from_place'] ?? ''),
|
||||
self::textOrNull2($d['to_place'] ?? ''),
|
||||
self::textOrNull2($fromPlace),
|
||||
self::textOrNull2($toPlace),
|
||||
// ---- v22 新增:城市 / 详细地址 / 坐标 ----
|
||||
self::textOrNull($fromCity),
|
||||
self::textOrNull($toCity),
|
||||
self::textOrNull($fromAddr),
|
||||
self::textOrNull($toAddr),
|
||||
$fromLng, $fromLat, $toLng, $toLat,
|
||||
];
|
||||
$cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
|
||||
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
|
||||
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?';
|
||||
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?,
|
||||
from_city=?, to_city=?, from_addr=?, to_addr=?,
|
||||
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
|
||||
if ($id !== null && $id > 0) {
|
||||
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?')
|
||||
->execute([...$fields, $id]);
|
||||
// ⚠️ `seat_info` / `gate` 在 UPDATE 里**不参与**(表单已不提交它们):
|
||||
// 写成 null 会把 TripLog 迁来的老数据抹掉。这两列从此只读不写。
|
||||
$colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
|
||||
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
|
||||
carrier=?, trip_no=?, from_place=?, to_place=?,
|
||||
from_city=?, to_city=?, from_addr=?, to_addr=?,
|
||||
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
|
||||
$keep = $fields;
|
||||
// 去掉 seat_info / gate 两项(它们在 $fields 里的下标是 15、16)
|
||||
array_splice($keep, 15, 2);
|
||||
$db->prepare('UPDATE trip_nodes SET ' . $colsKeep . ' WHERE id=?')
|
||||
->execute([...$keep, $id]);
|
||||
$nid = $id;
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at,
|
||||
node_type, status, category, position, stage, travel_mode, vehicle_id, line_name,
|
||||
carrier, trip_no, seat_info, gate, from_place, to_place)
|
||||
carrier, trip_no, seat_info, gate, from_place, to_place,
|
||||
from_city, to_city, from_addr, to_addr, from_lng, from_lat, to_lng, to_lat)
|
||||
VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')'
|
||||
)->execute([$familyId, $eventId, ...$fields]);
|
||||
$nid = (int)$db->lastInsertId();
|
||||
}
|
||||
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
|
||||
// ⚠️ 第 17 批:**角色标注功能已去掉**(用户第 3 条:「给参与人标注角色这个功能
|
||||
// 去掉,不要了」)→ 不再传 $d['roles']。saveNodeMembers 的第三个参数
|
||||
// 保留(老调用点/修正流程还在用),传空数组即可。
|
||||
self::saveNodeMembers($nid, $d['members'] ?? []);
|
||||
return $nid;
|
||||
}
|
||||
|
||||
/** 经纬度:只接受合理的数值范围,其余一律 null(脏数据不进库) */
|
||||
private static function coordOrNull($v): ?float
|
||||
{
|
||||
if ($v === null || $v === '' || !is_numeric($v)) {
|
||||
return null;
|
||||
}
|
||||
$f = (float)$v;
|
||||
// 经度 ±180、纬度 ±90;超出范围的显然是解析错了,宁可不存
|
||||
if (abs($f) > 180.0) {
|
||||
return null;
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。
|
||||
*
|
||||
@@ -897,14 +1018,29 @@ final class Trip
|
||||
public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void
|
||||
{
|
||||
$db = Database::get();
|
||||
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
|
||||
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
|
||||
VALUES (?,?,?)');
|
||||
// ⚠️ v24 第 17 批:**不再整表 DELETE 再插**,改成"差集增删"。
|
||||
// 原因:用户第 3 条把角色标注功能去掉了(表单不再提交 roles),
|
||||
// 如果这里照旧先删光再插,`role_tag` 就会被写成 null ——
|
||||
// TripLog 迁来的角色数据(虽然界面不再显示)会被**静默抹掉**。
|
||||
// 现在只做两件事:删掉"本次没选的人",插入"新选的人";
|
||||
// 已存在且仍被选中的,**原样不动**(role_tag 保住)。
|
||||
$keep = [];
|
||||
foreach ($personIds as $pid) {
|
||||
$pid = (int)$pid;
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
if ($pid > 0) {
|
||||
$keep[$pid] = true;
|
||||
}
|
||||
}
|
||||
if ($keep) {
|
||||
$in = implode(',', array_fill(0, count($keep), '?'));
|
||||
$db->prepare("DELETE FROM trip_node_members WHERE node_id = ? AND person_id NOT IN ($in)")
|
||||
->execute([$nodeId, ...array_keys($keep)]);
|
||||
} else {
|
||||
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
|
||||
}
|
||||
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
|
||||
VALUES (?,?,?)');
|
||||
foreach (array_keys($keep) as $pid) {
|
||||
$role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8');
|
||||
$ins->execute([$nodeId, $pid, $role !== '' ? $role : null]);
|
||||
}
|
||||
@@ -1005,6 +1141,62 @@ final class Trip
|
||||
return $s . ' – ' . date('Y年n月j日', $te);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段行程的时间,**精确到时分**(用户第 17 批 第 4 条末)。
|
||||
*
|
||||
* 用户原话:「行程中,时间明明具体到了时分秒,怎么不显示?」
|
||||
*
|
||||
* ⚠️ 原来用的是 `formatSpan()` —— 它只到**日**(`Y年n月j日`)。
|
||||
* 那个精度是给"旅程"(容器)用的:一次出行说"7月10日到7月12日"就够。
|
||||
* 但**一段行程**(比如"10:30 出发去机场")只显示日期等于没说 ——
|
||||
* 用户填进去的时分秒被吞掉了,看起来像"我填的时间没保存"。
|
||||
*
|
||||
* 展示规则(同一天时只写一次日期,避免"2026年7月10日 10:00 – 2026年7月10日 12:00"
|
||||
* 这种把同一天写两遍的啰嗦写法):
|
||||
* · 同一天:`2026年7月10日 10:00 – 12:00`
|
||||
* · 跨天: `2026年7月10日 10:00 – 7月12日 17:00`
|
||||
* · 只有开始:`2026年7月10日 10:00`
|
||||
* · 时分是 00:00(老数据只存了日期):**不写 00:00** —— 那会让人以为
|
||||
* "这个人专门挑午夜开始",其实只是没填时间。
|
||||
*/
|
||||
public static function nodeSpan(?string $start, ?string $end): string
|
||||
{
|
||||
if ($start === null || $start === '') {
|
||||
return '日期待补';
|
||||
}
|
||||
$ts = strtotime($start);
|
||||
if ($ts === false) {
|
||||
return '日期待补';
|
||||
}
|
||||
$sDate = date('Y年n月j日', $ts);
|
||||
$sTime = date('H:i', $ts);
|
||||
// 午夜 00:00 视为"没填具体时间"(日期控件的默认值),不显示它
|
||||
$sHasTime = $sTime !== '00:00';
|
||||
$head = $sDate . ($sHasTime ? ' ' . $sTime : '');
|
||||
|
||||
if ($end === null || $end === '') {
|
||||
return $head;
|
||||
}
|
||||
$te = strtotime($end);
|
||||
if ($te === false) {
|
||||
return $head;
|
||||
}
|
||||
$eTime = date('H:i', $te);
|
||||
$eHasTime = $eTime !== '00:00';
|
||||
// 同一天:后半段只写时分
|
||||
if (date('Y-m-d', $ts) === date('Y-m-d', $te)) {
|
||||
if (!$eHasTime && !$sHasTime) {
|
||||
return $sDate; // 两头都没时分 → 就是一天
|
||||
}
|
||||
if (!$eHasTime) {
|
||||
return $head . ' 起'; // 结束没填时分:说"从几点开始"更准
|
||||
}
|
||||
return $sDate . ($sHasTime ? ' ' . $sTime : '') . ' – ' . $eTime;
|
||||
}
|
||||
// 跨天:结束那天写月日(不重复年份)
|
||||
return $head . ' – ' . date('n月j日', $te) . ($eHasTime ? ' ' . $eTime : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 行程类型的**名字**。
|
||||
*
|
||||
|
||||
+21
-8
@@ -48,9 +48,13 @@ final class Vehicle
|
||||
/**
|
||||
* 财务里已登记的车子(**只读引用**,不复制进 vehicles)。
|
||||
*
|
||||
* 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。
|
||||
* 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源),
|
||||
* 这样模板里画下拉、画配置列表都能用同一套代码。
|
||||
* 🔴 **自 v24 第 17 批(用户第 3 条)起,界面上已不再使用** ——
|
||||
* 用户原话「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了」。
|
||||
* 方法本身保留的两个理由:
|
||||
* ① `vehicles.ledger_id` 是**软关联**(某台出行车在财务里对应哪条资产账目),
|
||||
* 将来"按车统计油费/保养"要用它把两边的数字对起来;
|
||||
* ② 删掉方法会让那层对应关系在代码里失去唯一的说明处(注释也跟着没了)。
|
||||
* **不要再把它接回选车下拉** —— 那正是这一批要改掉的东西。
|
||||
*/
|
||||
public static function fromLedger(int $familyId): array
|
||||
{
|
||||
@@ -86,16 +90,25 @@ final class Vehicle
|
||||
}
|
||||
|
||||
/**
|
||||
* 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。
|
||||
* 选车下拉用的列表 —— **只有 vehicles 表里的车**。
|
||||
*
|
||||
* @return array 每项都带 `from` = 'ledger' | 'local'
|
||||
* ⚠️ 用户 2026-10-05 第 17 批 第 3 条把上一批的做法**推翻**了:
|
||||
* 「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了。」
|
||||
* 第 16 批第 8 条当时说的是"财务里的车直接读取就可以",这一批明确不要了。
|
||||
* 理由(用户的思路):**出行的车**与**财务里的车子账目**是两回事 ——
|
||||
* 后者记的是"这台车值多少钱"(资产),没有车牌车型,混进选车列表里
|
||||
* 只会让人在"大众朗逸"和"家里的老车"之间发懵。
|
||||
* 所以现在选车只认 vehicles 表(自家 / 租的 / 借的都录在这里)。
|
||||
*
|
||||
* ⚠️ `fromLedger()` 方法**暂时保留但不再被任何界面调用**(见下方注释):
|
||||
* 财务里那些车是真实存在的资产,将来若要"按车统计油费/保养"还会用到;
|
||||
* 删掉它反而会丢掉那层对应关系(`vehicles.ledger_id` 软关联还指着它)。
|
||||
*
|
||||
* @return array 每项都带 `from` = 'local'
|
||||
*/
|
||||
public static function pickable(int $familyId): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::fromLedger($familyId) as $v) {
|
||||
$out[] = $v;
|
||||
}
|
||||
foreach (self::all($familyId) as $v) {
|
||||
$v['from'] = 'local';
|
||||
$out[] = $v;
|
||||
|
||||
@@ -3335,3 +3335,46 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
.trip-stats { gap: 8px 18px; }
|
||||
.ts-n { font-size: 17px; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
v24 第 17 批:城市的聚合 / 高德地址 / 表单精简
|
||||
用户 10 条(城市聚合、类型照 TripLog、只有自驾选车、飞机铁路只记车次、
|
||||
高德地址、弹框选人、去角色标注、图片右侧显示聚合城市、时间到时分秒)。
|
||||
============================================================ */
|
||||
|
||||
/* ---- 时间:精确到时分的那一行(比原来只到日期的那行略宽)---- */
|
||||
.node-time { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- 出发地 → 到达地:带导航链接 ---- */
|
||||
.node-route-line a {
|
||||
color: var(--brand-ink); text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(168, 74, 40, .35);
|
||||
}
|
||||
.node-route-line a:hover { border-bottom-style: solid; }
|
||||
|
||||
/* ---- 地址输入(高德联想)----
|
||||
可见文本框 + 隐藏的 addr/lng/lat/city;下面一行显示"详细地址"与导航链接。 */
|
||||
.loc-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.loc-label { font-size: 13px; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
||||
.loc-ico { font-size: 14px; }
|
||||
.loc-input { width: 100%; }
|
||||
.loc-extra { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; min-height: 18px; }
|
||||
.loc-detail {
|
||||
font-size: 12px; color: var(--muted); flex: 1 1 140px; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.loc-detail:empty { display: none; }
|
||||
.loc-nav {
|
||||
flex: 0 0 auto; font-size: 12px; color: var(--brand-ink);
|
||||
text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.loc-nav:hover { text-decoration: underline; }
|
||||
.loc-nav[hidden] { display: none; }
|
||||
|
||||
/* ---- 修正表单:里面的字段少,用同一套 .field 网格 ---- */
|
||||
.rev-form .field { margin-bottom: 0; }
|
||||
|
||||
/* ---- 窄屏:地址那两行不换行挤成一团 ---- */
|
||||
@media (max-width: 620px) {
|
||||
.loc-detail { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,26 @@ return [
|
||||
// 生成方式:openssl rand -hex 16
|
||||
'app_key' => '请填写32位随机十六进制串',
|
||||
|
||||
// ---------------- 高德地图(旅程的地址联想 / 逆地理 / 导航)----------------
|
||||
// 用户 2026-10-05 第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,
|
||||
// 记录详细地址」。
|
||||
//
|
||||
// ⚠️ 这里要用到**两把不同的 key**(在高德开放平台控制台分别申请):
|
||||
// · `key` —— 既是 **Web 服务** key(服务端逆地理 regeo),
|
||||
// 也是 **JS API** key(前端地址输入联想)。
|
||||
// 若两者分开申请,把 Web 服务的填这里,
|
||||
// JS API 那把填在 `js_key`(见下);
|
||||
// · `security_js` —— JS API 的**安全密钥**(2021-12-02 之后申请的 key 都要配)。
|
||||
//
|
||||
// 不填也不影响使用:地址联想不加载、城市聚合自动回落到**离线解析**
|
||||
// (从地址文本里提「XX市」),功能都还在,只是联想与逆地理的准确度差一些。
|
||||
'amap' => [
|
||||
'key' => '',
|
||||
'security_js' => '',
|
||||
// 高德 URI API 的 `src`(调用来源,只用于统计,任意英数字串都行)
|
||||
'nav_src' => '',
|
||||
],
|
||||
|
||||
// ---------------- 调试 ----------------
|
||||
// true 时引导页会打印数据库连接错误的详情。
|
||||
// ⚠️ 线上务必保持 false —— 错误详情里可能带数据库账号名。
|
||||
|
||||
@@ -2993,7 +2993,10 @@ switch ($r) {
|
||||
$familyId = (int)Auth::familyId();
|
||||
View::render('trips/vehicles', [
|
||||
'title' => '车辆管理',
|
||||
'ledger' => Vehicle::fromLedger($familyId),
|
||||
// ⚠️ v24 第 17 批 第 3 条:**不再传** ledger(财务里的车)——
|
||||
// 用户原话「车辆下拉框,不需要出现什么财务里登记的车」。
|
||||
// 模板里那一整块也删掉了。fromLedger() 方法仍在模型层保留
|
||||
// (将来按车统计油费/保养要用它跟 vehicles.ledger_id 对上)。
|
||||
'mine' => Vehicle::all($familyId),
|
||||
], 'trips');
|
||||
break;
|
||||
@@ -3142,11 +3145,9 @@ switch ($r) {
|
||||
$isEdit = $r === 'trips/node_update';
|
||||
$nid = $isEdit ? (int)($_POST['id'] ?? 0) : 0;
|
||||
// v24 第 16 批:表单照 TripLog 的节点,字段全量(用户第 7 条)。
|
||||
// 参与人的角色标签走 roles[person_id] => 角色(TripLog 的 role_tag)。
|
||||
$roles = [];
|
||||
foreach ((array)($_POST['roles'] ?? []) as $pid => $role) {
|
||||
$roles[(int)$pid] = (string)$role;
|
||||
}
|
||||
// ⚠️ 第 17 批 用户第 3 条把「角色标注」去掉了("这个功能去掉,不要了"),
|
||||
// 所以这里**不再读** roles;座位/登机口也不再从表单收(字段已从界面移除,
|
||||
// 数据库那两列保留,详见 Trip::saveNode 的处理)。
|
||||
$d = [
|
||||
'title' => trim((string)($_POST['title'] ?? '')),
|
||||
'detail' => trim((string)($_POST['detail'] ?? '')),
|
||||
@@ -3159,17 +3160,25 @@ switch ($r) {
|
||||
// 新建时用户在表单里选的「计划 / 实际」;编辑时表单带的是隐藏 stage
|
||||
'stage' => (string)($_POST['stage_pick'] ?? $_POST['stage'] ?? 'plan'),
|
||||
'members' => filter_family_person_ids((array)($_POST['members'] ?? []), $familyId),
|
||||
'roles' => $roles,
|
||||
// ---- v21:交通与起终点(TripLog 同款)----
|
||||
// ---- v21:交通(TripLog 同款)----
|
||||
'travel_mode' => (string)($_POST['travel_mode'] ?? ''),
|
||||
'vehicle_id' => (int)($_POST['vehicle_id'] ?? 0),
|
||||
'line_name' => trim((string)($_POST['line_name'] ?? '')),
|
||||
'carrier' => trim((string)($_POST['carrier'] ?? '')),
|
||||
'trip_no' => trim((string)($_POST['trip_no'] ?? '')),
|
||||
'seat_info' => trim((string)($_POST['seat_info'] ?? '')),
|
||||
'gate' => trim((string)($_POST['gate'] ?? '')),
|
||||
// ---- v22:出发地 / 到达地(地点名 + 详细地址 + 坐标 + 城市)----
|
||||
// 用户第 17 批 第 3 条:「出发地和到达地,都用高德地图的接口,记录详细地址」。
|
||||
// 城市由前端联想带回(`*_city`),带不回时 Trip::saveNode 里用 App\Geo 解析。
|
||||
'from_place' => trim((string)($_POST['from_place'] ?? '')),
|
||||
'to_place' => trim((string)($_POST['to_place'] ?? '')),
|
||||
'from_addr' => trim((string)($_POST['from_addr'] ?? '')),
|
||||
'to_addr' => trim((string)($_POST['to_addr'] ?? '')),
|
||||
'from_lng' => trim((string)($_POST['from_lng'] ?? '')),
|
||||
'from_lat' => trim((string)($_POST['from_lat'] ?? '')),
|
||||
'to_lng' => trim((string)($_POST['to_lng'] ?? '')),
|
||||
'to_lat' => trim((string)($_POST['to_lat'] ?? '')),
|
||||
'from_city' => trim((string)($_POST['from_city'] ?? '')),
|
||||
'to_city' => trim((string)($_POST['to_city'] ?? '')),
|
||||
];
|
||||
if ($d['title'] === '') {
|
||||
flash('err', '这段行程得有个名字。');
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/**
|
||||
* 地址输入(高德联想 + 详细地址 + 坐标),v24 第 17 批 用户第 3 条。
|
||||
*
|
||||
* 用户原话:「出发地和到达地,都用**高德地图的接口**,记录详细地址,
|
||||
* 这个功能,triplog 也有,如果之前有数据,里面记录了行程的详细地址,
|
||||
* 也都把数据迁移过来。」
|
||||
*
|
||||
* 界面照 TripLog 的 nodes/form.php:
|
||||
* 一个可见的文本框(人能看到、能改、能自己敲)+ 四个隐藏字段
|
||||
* (name / address / lng / lat —— 选中联想项后由 JS 填上)。
|
||||
* **有坐标时**旁边出一个「🧭 导航到这里」链接(高德 URI API)。
|
||||
*
|
||||
* ⚠️ 三个必须做对的细节:
|
||||
* ① **没配 key 也要能用**:SDK 不加载时文本框就是普通输入框,
|
||||
* 用户手敲地名照样能存(只是没有详细地址与坐标)。
|
||||
* 降级路径不能是"功能消失",只能是"辅助变少"。
|
||||
* ② **assoc 用 map 而不是 select**:TripLog 用 AMap.AutoComplete 直接挂到 input 上,
|
||||
* 能边打边出候选,比"点一下弹搜索框"顺手得多。
|
||||
* ③ **不要 `<label for>`**:这个片段在一个页面里会出现两次(出发地/到达地),
|
||||
* for/id 对不上就会互相抢焦点。用包裹式 label。
|
||||
*
|
||||
* 用法(require 前设好):
|
||||
* $locPrefix 前缀('from' | 'to'),决定所有 id 与字段名
|
||||
* $locLabel 标签文字(「出发地」「到达地」)
|
||||
* $locIcon 标签前的小图标(可选)
|
||||
* $locPlaceholder 占位提示
|
||||
* $locRow 行数据(可选;编辑时回填):name/address/lng/lat 四个键
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$locPrefix = (string)($locPrefix ?? 'from');
|
||||
$locLabel = (string)($locLabel ?? '地点');
|
||||
$locIcon = (string)($locIcon ?? '');
|
||||
$locPlaceholder = (string)($locPlaceholder ?? '输入地址,如「青岛北站」');
|
||||
$locRow = is_array($locRow ?? null) ? $locRow : [];
|
||||
|
||||
$locName = (string)($locRow['name'] ?? '');
|
||||
$locAddr = (string)($locRow['address'] ?? '');
|
||||
$locLng = (string)($locRow['lng'] ?? '');
|
||||
$locLat = (string)($locRow['lat'] ?? '');
|
||||
|
||||
// 导航链接:有坐标走 navigation,没坐标退化成"按名字搜"
|
||||
$locNav = \App\Geo::navUrl($locLng, $locLat, $locName);
|
||||
$locId = 'loc-' . $locPrefix;
|
||||
?>
|
||||
<div class="loc-field" data-loc="<?= e($locPrefix) ?>">
|
||||
<label class="loc-label" for="<?= e($locId) ?>-input">
|
||||
<?php if ($locIcon !== ''): ?><span class="loc-ico"><?= e($locIcon) ?></span><?php endif; ?>
|
||||
<?= e($locLabel) ?>
|
||||
</label>
|
||||
<input class="input loc-input" type="text"
|
||||
id="<?= e($locId) ?>-input"
|
||||
name="<?= e($locPrefix) ?>_place"
|
||||
value="<?= e($locName) ?>"
|
||||
placeholder="<?= e($locPlaceholder) ?>"
|
||||
autocomplete="off"
|
||||
data-loc-input>
|
||||
<?php /* 高德没加载 / 用户自己敲的地名:这四个字段就是兜底存放处 */ ?>
|
||||
<input type="hidden" name="<?= e($locPrefix) ?>_addr" value="<?= e($locAddr) ?>">
|
||||
<input type="hidden" name="<?= e($locPrefix) ?>_lng" value="<?= e($locLng) ?>">
|
||||
<input type="hidden" name="<?= e($locPrefix) ?>_lat" value="<?= e($locLat) ?>">
|
||||
<?php /* 城市由前端选中时带回(高德 addressComponent 直接给城市名),
|
||||
带不回就由服务端 App\Geo 解析 —— 两条路都通 */ ?>
|
||||
<input type="hidden" name="<?= e($locPrefix) ?>_city" value="<?= e((string)($locRow['city'] ?? '')) ?>">
|
||||
|
||||
<div class="loc-extra">
|
||||
<span class="loc-detail" data-loc-detail><?= e($locAddr !== '' && $locAddr !== $locName ? $locAddr : '') ?></span>
|
||||
<a class="loc-nav" data-loc-nav
|
||||
href="<?= e($locNav !== '' ? $locNav : '#') ?>"
|
||||
target="_blank" rel="noopener"
|
||||
<?= $locNav === '' ? 'hidden' : '' ?>>🧭 导航到这里</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
// 只 unset 本片段自己的变量,宿主模板一个都不碰(与 _datetime.php 同一约定)
|
||||
unset($locPrefix, $locLabel, $locIcon, $locPlaceholder, $locRow,
|
||||
$locName, $locAddr, $locLng, $locLat, $locNav, $locId);
|
||||
@@ -39,11 +39,12 @@ $rvN = (int)($rvNode['revision_n'] ?? 0);
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<span class="tip">默认是计划的时间,改成实际发生的即可。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="<?= e($rvPrefix) ?>-place">实际地点</label>
|
||||
<input class="input" id="<?= e($rvPrefix) ?>-place" name="place"
|
||||
value="<?= e($rvNode['place'] ?? '') ?>" placeholder="没变就留空">
|
||||
</div>
|
||||
<?php /* 用户第 17 批 第 3 条:「参与人那里,和记录电影人一样,**弹框选择**,
|
||||
不要全列出来,太多了。」——修正表单也是"选参与人",同样走统一组件。
|
||||
⚠️ 本批同时**去掉了「实际地点」输入框**:用户第 1 条把"地点"这件事
|
||||
收敛成了**出发地 / 到达地**(城市由它们聚合),单留一个 free-text
|
||||
「实际地点」会让人分不清它和出发地是什么关系。
|
||||
修正要记的就是用户第 13 批原话里那三样:实际时间 / 实际参与人 / 说明。 */ ?>
|
||||
<div class="field full">
|
||||
<label for="<?= e($rvPrefix) ?>-detail">这次修正的说明</label>
|
||||
<input class="input" id="<?= e($rvPrefix) ?>-detail" name="detail"
|
||||
@@ -52,15 +53,15 @@ $rvN = (int)($rvNode['revision_n'] ?? 0);
|
||||
<?php if (!empty($rvPeople)): ?>
|
||||
<div class="field full">
|
||||
<label>实际参与的人</label>
|
||||
<div class="nf-members">
|
||||
<?php foreach ($rvPeople as $p): ?>
|
||||
<label class="check-item">
|
||||
<input type="checkbox" name="members[]" value="<?= (int)$p['id'] ?>"
|
||||
<?= in_array((int)$p['id'], $rvMembers, true) ? 'checked' : '' ?>>
|
||||
<span><?= e(person_name($p)) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
$ppName = 'members[]';
|
||||
$ppPeople = $rvPeople;
|
||||
$ppSelected = $rvMembers;
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 选择实际参与的人';
|
||||
$ppTitle = '这次实际去的都有谁';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
+208
-94
@@ -1,11 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* 一段行程的新增 / 编辑 —— **独立页面**(v24 第 16 批 用户第 5 / 6 / 7 条)。
|
||||
* 一段行程的新增 / 编辑 —— **独立页面**(v24 第 16 批 用户第 5 / 6 / 7 条;
|
||||
* 第 17 批按用户第 3 条再改一轮)。
|
||||
*
|
||||
* 用户原话:
|
||||
* · 第 5 条:「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
|
||||
* · 第 6 条:「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
|
||||
* · 第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
|
||||
* · 第 17 批 第 3 条(本次):
|
||||
* ① 「出行方式,**只有选择自驾**,才让选车辆」;
|
||||
* ② 「飞机、铁路,只记录**车次、航班号**就可以…**座位什么的不记录了**」;
|
||||
* ③ 「出发地和到达地,都用**高德地图的接口,记录详细地址**」;
|
||||
* ④ 「参与人那里,和记录电影人一样,**弹框选择**,不要全列出来,太多了」;
|
||||
* ⑤ 「给参与人标注角色这个功能**去掉**,不要了」。
|
||||
*
|
||||
* ⚠️ 为什么必须独立成页(而不是继续用 `<details>` 内联):
|
||||
* TripLog 的节点表单有十几组字段(类型/时间/出行方式/交通细节/起终点/内容/
|
||||
@@ -13,17 +20,17 @@
|
||||
* 更要命的是**编辑**时每个行程内联一份表单 → 页面上 N 份表单互相干扰
|
||||
* (元素 id 撞车、密码管理器逐个扫描变卡)。这正是用户说的"把页面撑破"。
|
||||
*
|
||||
* 字段顺序照 TripLog 的 nodes/form.php:
|
||||
* 类型 → 起止时间 → 标题 → 出行方式(+车辆+交通细节)→ 起点 → 终点
|
||||
* → 内容 → 参与人 → 照片 →(编辑时)状态与花销。
|
||||
* 字段顺序照 TripLog 的 nodes/form.php(第 17 批又砍掉了两处冗余):
|
||||
* 类型 → 标题 → 起止时间 → 出行方式(自驾才有车辆)→ 线路/车次/承运方
|
||||
* → 出发地 → 到达地 → 内容 → 参与人 → 照片 →(编辑时)状态与花销。
|
||||
*
|
||||
* 变量(require 前设好):
|
||||
* $ev 所属旅程(events 行,带 title)
|
||||
* $n 被编辑的行程;null = 新增
|
||||
* $stage 'plan'|'actual'(新增时由按钮决定;编辑时取行程自己的)
|
||||
* $people 名册
|
||||
* $people 名册(**只用于弹框组件**,不再平铺成一长串勾选框)
|
||||
* $types 节点类型(NodeType::all)
|
||||
* $vehicles 可挑的车(Vehicle::pickable,含"来自财务"的只读候选)
|
||||
* $vehicles 可挑的车(Vehicle::pickable —— 第 17 批起**只有自家车辆库**)
|
||||
* $expenses 已有花销(编辑时)
|
||||
* $errors 校验错误
|
||||
*/
|
||||
@@ -41,15 +48,30 @@ if (!isset(\App\Trip::NODE_STAGES[$stage])) { $stage = 'plan'; }
|
||||
$curType = (string)($n['node_type'] ?? '');
|
||||
$curMode = (string)($n['travel_mode'] ?? '');
|
||||
$picked = array_map('intval', array_column((array)($n['members'] ?? []), 'id'));
|
||||
|
||||
// 参与人角色:person_id => role
|
||||
$roles = [];
|
||||
foreach ((array)($n['members'] ?? []) as $m) {
|
||||
if (!empty($m['role_tag'])) {
|
||||
$roles[(int)$m['id']] = (string)$m['role_tag'];
|
||||
}
|
||||
}
|
||||
$back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
|
||||
// 车辆下拉:只有**自驾**才显示(用户第 3 条)。服务端先按已存值决定初始显隐,
|
||||
// 之后由 app.js 里的 data-mode-select 跟着下拉变。
|
||||
$needsVehicle = \App\Trip::modeNeedsVehicle($curMode);
|
||||
// 车次/航班号:只有火车与飞机才显示(用户第 3 条)
|
||||
$needsTicket = in_array($curMode, \App\Trip::TICKET_MODES, true);
|
||||
|
||||
// 起终点(编辑时从库里回填;老数据列是 varchar,坐标是 decimal)
|
||||
$fromRow = [
|
||||
'name' => (string)($n['from_place'] ?? ''),
|
||||
'address' => (string)($n['from_addr'] ?? ''),
|
||||
'lng' => (string)($n['from_lng'] ?? ''),
|
||||
'lat' => (string)($n['from_lat'] ?? ''),
|
||||
'city' => (string)($n['from_city'] ?? ''),
|
||||
];
|
||||
$toRow = [
|
||||
'name' => (string)($n['to_place'] ?? ''),
|
||||
'address' => (string)($n['to_addr'] ?? ''),
|
||||
'lng' => (string)($n['to_lng'] ?? ''),
|
||||
'lat' => (string)($n['to_lat'] ?? ''),
|
||||
'city' => (string)($n['to_city'] ?? ''),
|
||||
];
|
||||
$amap = \App\Geo::jsConfig();
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>
|
||||
@@ -76,7 +98,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-grid">
|
||||
<?php /* ---- 类型(TripLog 的第一个字段)---- */ ?>
|
||||
<?php /* ---- 类型(TripLog 的第一个字段;第 17 批 第 2 条已补齐到 8 种)---- */ ?>
|
||||
<div class="field">
|
||||
<label for="nf-type">类型 *</label>
|
||||
<select class="select" id="nf-type" name="node_type">
|
||||
@@ -109,7 +131,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 起止时间:一个范围控件 ---- */ ?>
|
||||
<?php /* ---- 起止时间:一个范围控件(精确到秒,用户第 17 批问"怎么不显示时分秒")---- */ ?>
|
||||
<div class="field dt-range full">
|
||||
<label>开始 / 结束</label>
|
||||
<?php $dtName = 'starts_at'; $dtValue = $n['starts_at'] ?? '';
|
||||
@@ -118,11 +140,10 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
|
||||
$dtId = 'nf-e'; $dtSeconds = true; $dtRange = 'end';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<span class="tip">结束时间可不填。</span>
|
||||
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 出行方式 + 车辆(TripLog 的 travel_modes)----
|
||||
用户第 8 条:车辆从财务里**读**,也可以加自己的(租的 / 借的)。 */ ?>
|
||||
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
|
||||
<div class="field">
|
||||
<label for="nf-mode">出行方式</label>
|
||||
<select class="select" id="nf-mode" name="travel_mode" data-mode-select>
|
||||
@@ -135,77 +156,65 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field" data-mode-vehicle hidden>
|
||||
<?php /* ---- 车辆:**只有自驾**才出现(用户第 17 批 第 3 条)----
|
||||
用户原话:「出行方式,只有选择自驾,才让选车辆,车辆下拉框,
|
||||
不需要出现什么财务里登记的车,只把车辆列出来就行了。」 */ ?>
|
||||
<div class="field" data-mode-vehicle<?= $needsVehicle ? '' : ' hidden' ?>>
|
||||
<label for="nf-veh">车辆</label>
|
||||
<select class="select" id="nf-veh" name="vehicle_id">
|
||||
<option value="">— 不指定 —</option>
|
||||
<?php
|
||||
$curVeh = (int)($n['vehicle_id'] ?? 0);
|
||||
$ledgerShown = false;
|
||||
foreach ($vehicles as $v):
|
||||
$isLedger = (($v['from'] ?? '') === 'ledger');
|
||||
if ($isLedger && !$ledgerShown) {
|
||||
echo '<optgroup label="财务里已登记的车">';
|
||||
$ledgerShown = true;
|
||||
}
|
||||
?>
|
||||
<option value="<?= $isLedger ? (int)$v['id'] : (int)$v['id'] ?>"
|
||||
<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
|
||||
<?= e(\App\Vehicle::label($v) . ($isLedger ? '(来自财务)' : '')) ?>
|
||||
<?php $curVeh = (int)($n['vehicle_id'] ?? 0); ?>
|
||||
<?php foreach ($vehicles as $v): ?>
|
||||
<option value="<?= (int)$v['id'] ?>"<?= $curVeh === (int)$v['id'] ? ' selected' : '' ?>>
|
||||
<?= e(\App\Vehicle::label($v)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($ledgerShown): ?></optgroup><?php endif; ?>
|
||||
</select>
|
||||
<span class="tip">
|
||||
财务里登记过的车子会直接列在这里;租的车、借别人的车到
|
||||
「设置 → 车辆管理」加一台。
|
||||
<?php if (!$vehicles): ?>
|
||||
车辆库还是空的 —— 到「设置 → 车辆管理」加一台(自家 / 租的 / 借的都能加)。
|
||||
<?php else: ?>
|
||||
租的车、借别人的车也到「设置 → 车辆管理」里加一台。
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 交通细节(照 TripLog:线路名 / 车次 / 承运方 / 座位 / 登机口)---- */ ?>
|
||||
<?php /* ---- 交通细节:**只有火车/飞机**才需要车次航班号(用户第 17 批 第 3 条)----
|
||||
用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
|
||||
因为可能是多个人同行,座位什么的不记录了。」
|
||||
⚠️ 所以这里**没有**「座位 / 车厢」与「登机口 / 站台」两个输入框
|
||||
—— 它们对"一家人出行"没有意义(五六张票座位不在一起,
|
||||
登机口出发当天才知道)。数据库那两列保留(老数据不丢),界面不再出现。 */ ?>
|
||||
<div class="field" data-mode-ticket<?= $needsTicket ? '' : ' hidden' ?>>
|
||||
<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 /* 线路名与承运方:火车/飞机时最有意义(「京沪高铁」「中国铁路」),
|
||||
也允许其他方式填(比如「轮渡 5 号线」)—— 所以不按方式隐藏。 */ ?>
|
||||
<div class="field">
|
||||
<label for="nf-line">线路名</label>
|
||||
<input class="input" id="nf-line" name="line_name"
|
||||
value="<?= e(old('line_name', $n['line_name'] ?? '')) ?>" placeholder="如:京沪高铁、地铁 3 号线">
|
||||
</div>
|
||||
<div class="field">
|
||||
<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">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nf-carrier">承运方</label>
|
||||
<input class="input" id="nf-carrier" name="carrier"
|
||||
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、滴滴">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nf-seat">座位 / 车厢</label>
|
||||
<input class="input" id="nf-seat" name="seat_info"
|
||||
value="<?= e(old('seat_info', $n['seat_info'] ?? '')) ?>" placeholder="如:7 车 12F">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nf-gate">登机口 / 站台</label>
|
||||
<input class="input" id="nf-gate" name="gate"
|
||||
value="<?= e(old('gate', $n['gate'] ?? '')) ?>" placeholder="如:T3 登机口 B12">
|
||||
value="<?= e(old('carrier', $n['carrier'] ?? '')) ?>" placeholder="如:中国铁路、国航">
|
||||
</div>
|
||||
|
||||
<?php /* ---- 起点 / 终点(TripLog 的 start/end_location)---- */ ?>
|
||||
<?php /* ---- 出发地 / 到达地:高德联想 + 详细地址(用户第 17 批 第 3 条)----
|
||||
用户原话:「出发地和到达地,都用高德地图的接口,记录详细地址」。 */ ?>
|
||||
<div class="field">
|
||||
<label for="nf-from">出发地</label>
|
||||
<input class="input" id="nf-from" name="from_place"
|
||||
value="<?= e(old('from_place', $n['from_place'] ?? '')) ?>" placeholder="如:家里">
|
||||
<?php $locPrefix = 'from'; $locLabel = '出发地'; $locIcon = '🚩';
|
||||
$locPlaceholder = '输入地址,如「家里」「青岛北站」'; $locRow = $fromRow;
|
||||
require __DIR__ . '/_loc_field.php'; ?>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nf-to">到达地</label>
|
||||
<input class="input" id="nf-to" name="to_place"
|
||||
value="<?= e(old('to_place', $n['to_place'] ?? '')) ?>" placeholder="如:青岛北站">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="nf-place">地点</label>
|
||||
<input class="input" id="nf-place" name="place"
|
||||
value="<?= e(old('place', $n['place'] ?? '')) ?>" placeholder="这一段主要在哪(用于统计城市)">
|
||||
<span class="tip">旅程页上的"去了几个城市"就是按这里统计的。</span>
|
||||
<?php $locPrefix = 'to'; $locLabel = '到达地'; $locIcon = '🏁';
|
||||
$locPlaceholder = '输入地址,如「青岛北站」「栈桥」'; $locRow = $toRow;
|
||||
require __DIR__ . '/_loc_field.php'; ?>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -225,40 +234,30 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
placeholder="这一段发生了什么"><?= e(old('detail', $n['detail'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 参与人(+角色,TripLog 的 role_tag)---- */ ?>
|
||||
<?php /* ---- 参与人:**弹框选人**(用户第 17 批 第 3 条)----
|
||||
用户原话:「参与人那里,和记录电影人一样,弹框选择,不要全列出来,太多了。
|
||||
给参与人标注角色这个功能去掉,不要了。」
|
||||
⚠️ 所以:① 用全站统一的选人组件(_person_picker.php,与观影同一个);
|
||||
② **没有**角色输入框了(上一批那个 <details> 折叠区删掉了)。 */ ?>
|
||||
<div class="field full">
|
||||
<label>参与的人</label>
|
||||
<?php if (!$people): ?>
|
||||
<p class="empty-hint">名册里还没有人。</p>
|
||||
<?php else: ?>
|
||||
<div class="nf-members">
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php $pid = (int)$p['id']; ?>
|
||||
<label class="check-item">
|
||||
<input type="checkbox" name="members[]" value="<?= $pid ?>"
|
||||
<?= in_array($pid, $picked, true) ? 'checked' : '' ?>>
|
||||
<span><?= e(person_name($p)) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<details class="role-set">
|
||||
<summary class="link-quiet">给参与的人标个角色(可选)</summary>
|
||||
<div class="role-grid">
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php $pid = (int)$p['id']; ?>
|
||||
<div class="role-row">
|
||||
<span class="role-who"><?= e(person_name($p)) ?></span>
|
||||
<input class="input" name="roles[<?= $pid ?>]" maxlength="30"
|
||||
value="<?= e($roles[$pid] ?? '') ?>" placeholder="如:司机 / 摄影">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<span class="tip">不填也没关系;填了会显示在那个人名字旁边。</span>
|
||||
</details>
|
||||
<?php
|
||||
$ppName = 'members[]';
|
||||
$ppPeople = $people;
|
||||
$ppSelected = $picked;
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 添加参与的人';
|
||||
$ppTitle = '这一段里有谁';
|
||||
$ppTip = '不勾 = 这段没单独记参与人(旅程页仍会按各段汇总)。';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 照片(用户第 3 条:图片视频都针对一次具体行程)---- */ ?>
|
||||
<?php /* ---- 照片(用户第 16 批 第 3 条:图片视频都针对一次具体行程)---- */ ?>
|
||||
<div class="field full">
|
||||
<label for="nf-photo">这一段行程的照片</label>
|
||||
<?php if ($isEdit && !empty($n['attachs'])): ?>
|
||||
@@ -295,7 +294,7 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
</div>
|
||||
|
||||
<?php /* ==================== 花销(只在编辑时出现)====================
|
||||
用户第 2 条要"一共花了多少钱"、本次确认「行程级花销记录」。
|
||||
用户第 2 条要"一共花了多少钱"、第 16 批确认「行程级花销记录」。
|
||||
⚠️ 新增时不给花销面板:那时行程 id 还不存在,花销挂不上(先存行程再加花销,
|
||||
少一种"提交了但没存上"的状态)。 */ ?>
|
||||
<?php if ($isEdit): ?>
|
||||
@@ -362,3 +361,118 @@ $back = url('trips/show', ['id' => (int)$ev['id']]);
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ==================== 高德 SDK(按需加载,与 TripLog 同一套做法)====================
|
||||
⚠️ SDK 加载**失败也必须能用**:文本框就是普通输入框,用户手敲地名照样能存。
|
||||
所以这段脚本里所有 AMap 的用法都放在 `__AMAP_LOADED` 判断之后,
|
||||
并且给"搜索这个地名"兜底链接单独一条路径(不依赖 SDK)。 */ ?>
|
||||
<?php if ($amap['key'] !== ''): ?>
|
||||
<script>
|
||||
window._AMapSecurityConfig = { securityJsCode: '<?= e($amap['security']) ?>' };
|
||||
window.__AMAP_LOADED = false;
|
||||
</script>
|
||||
<script src="https://webapi.amap.com/maps?v=2.0&key=<?= e($amap['key']) ?>&plugin=AMap.AutoComplete"
|
||||
onload="window.__AMAP_LOADED = true;"
|
||||
onerror="window.__AMAP_ERROR = 'script load failed';"></script>
|
||||
<?php endif; ?>
|
||||
<script>
|
||||
/* ============================================================
|
||||
地址联想(v24 第 17 批 用户第 3 条)
|
||||
照 TripLog 的做法:AMap.AutoComplete 直接挂在 input 上,边打边出候选;
|
||||
选中后把 name / address / lng / lat / city **同时**写进五个字段。
|
||||
⚠️ 高德没加载时整个块跳过,input 仍是普通文本框(降级可用)。
|
||||
⚠️ 用 IIFE 包起来:这个页面只有一份表单,但函数名别污染全局
|
||||
(app.js 里也有通用初始化,命名撞了会互相覆盖)。
|
||||
============================================================ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function bindLoc(prefix) {
|
||||
var input = document.getElementById('loc-' + prefix + '-input');
|
||||
if (!input) { return; }
|
||||
var wrap = input.closest('.loc-field');
|
||||
if (!wrap) { return; }
|
||||
var fAddr = wrap.querySelector('input[name="' + prefix + '_addr"]');
|
||||
var fLng = wrap.querySelector('input[name="' + prefix + '_lng"]');
|
||||
var fLat = wrap.querySelector('input[name="' + prefix + '_lat"]');
|
||||
var fCity = wrap.querySelector('input[name="' + prefix + '_city"]');
|
||||
var dTail = wrap.querySelector('[data-loc-detail]');
|
||||
var navEl = wrap.querySelector('[data-loc-nav]');
|
||||
|
||||
/* 人选完之后(或手敲完)更新"详细地址"那行字与导航链接。
|
||||
导航链接用高德 URI API —— **没有 SDK 也能用**,所以不放在 __AMAP_LOADED 里。 */
|
||||
function paint() {
|
||||
var name = input.value.trim();
|
||||
var addr = fAddr ? fAddr.value : '';
|
||||
var lng = fLng ? fLng.value : '';
|
||||
var lat = fLat ? fLat.value : '';
|
||||
if (dTail) { dTail.textContent = (addr && addr !== name) ? addr : ''; }
|
||||
if (navEl) {
|
||||
if (lng !== '' && lat !== '') {
|
||||
navEl.href = 'https://uri.amap.com/navigation?to=' + encodeURIComponent(lng + ',' + lat + (name ? ',' + name : ''))
|
||||
+ '&mode=car&policy=1&coordinate=gaode&callnative=1';
|
||||
navEl.hidden = false;
|
||||
} else if (name !== '') {
|
||||
navEl.href = 'https://uri.amap.com/search?keyword=' + encodeURIComponent(name);
|
||||
navEl.hidden = false;
|
||||
} else {
|
||||
navEl.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 手敲也要更新(用户可能不用联想,直接打字)
|
||||
input.addEventListener('change', paint);
|
||||
input.addEventListener('blur', paint);
|
||||
|
||||
if (!window.__AMAP_LOADED || typeof AMap === 'undefined' || !AMap.AutoComplete) {
|
||||
return; /* 降级:没有联想,但输入与保存都正常 */
|
||||
}
|
||||
try {
|
||||
var ac = new AMap.AutoComplete({ input: input });
|
||||
ac.on('select', function (e) {
|
||||
var d = (e && e.poi) ? e.poi : null;
|
||||
if (!d) { return; }
|
||||
var loc = d.location || null; // 可能是 AMap.LngLat,也可能是 {lng,lat}
|
||||
var lng = loc ? (loc.lng !== undefined ? loc.lng : (loc.getLng ? loc.getLng() : '')) : '';
|
||||
var lat = loc ? (loc.lat !== undefined ? loc.lat : (loc.getLat ? loc.getLat() : '')) : '';
|
||||
if (fAddr) { fAddr.value = d.address || ''; }
|
||||
if (fLng) { fLng.value = (lng === null || lng === undefined) ? '' : lng; }
|
||||
if (fLat) { fLat.value = (lat === null || lat === undefined) ? '' : lat; }
|
||||
/* 高德把城市放在 addressComponent / pname+cityname 上 —— 尽量带回城市,
|
||||
省掉服务端一次逆地理请求(用户第 1 条要的就是城市)。 */
|
||||
if (fCity) {
|
||||
if (d.addressComponent && d.addressComponent.city) {
|
||||
fCity.value = d.addressComponent.city;
|
||||
} else if (d.cityname) {
|
||||
fCity.value = d.cityname;
|
||||
} else {
|
||||
fCity.value = '';
|
||||
}
|
||||
}
|
||||
paint();
|
||||
});
|
||||
} catch (err) {
|
||||
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级,不打扰用户 */
|
||||
}
|
||||
}
|
||||
|
||||
bindLoc('from');
|
||||
bindLoc('to');
|
||||
|
||||
/* 出行方式 → 显隐车辆 / 车次(用户第 17 批 第 3 条) */
|
||||
var modeSel = document.querySelector('[data-mode-select]');
|
||||
if (modeSel) {
|
||||
var vehBox = document.querySelector('[data-mode-vehicle]');
|
||||
var tikBox = document.querySelector('[data-mode-ticket]');
|
||||
var DRIVE = ['drive']; // 与 Trip::DRIVE_MODES 一致
|
||||
var TICKET = ['train', 'plane']; // 与 Trip::TICKET_MODES 一致
|
||||
var sync = function () {
|
||||
var v = modeSel.value;
|
||||
if (vehBox) { vehBox.hidden = DRIVE.indexOf(v) === -1; }
|
||||
if (tikBox) { tikBox.hidden = TICKET.indexOf(v) === -1; }
|
||||
};
|
||||
modeSel.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
+34
-15
@@ -75,7 +75,10 @@ $groupMeta = [
|
||||
⚠️ 第 1 条:「题图应该按照 16:9 显示,因为添加的时候,是按照这个比例裁剪的,
|
||||
现在的显示比例不对」—— 之前 CSS 写的是 4:3,把 16:9 的图裁掉两边。
|
||||
16:9 这个数字与 ImageSlot 里 cover 槽位的比例**必须一致**,
|
||||
部署断言里有一条专门比对它。 */ ?>
|
||||
部署断言里有一条专门比对它。
|
||||
⚠️ 第 17 批 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,
|
||||
而不是终点地址。」—— 城市来自 `Trip::cities()`(出发地 ∪ 到达地,去重),
|
||||
见 summary() → cities()。 */ ?>
|
||||
<div class="card card-pad trip-hero">
|
||||
<div class="trip-hero-cover">
|
||||
<?php if (!empty($t['cover_key'])): ?>
|
||||
@@ -178,7 +181,10 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<?php foreach ($list as $n): ?>
|
||||
<?php
|
||||
$nid = (int)$n['id'];
|
||||
$span = \App\Trip::formatSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
|
||||
// ⚠️ 第 17 批 第 4 条末:「行程中,时间明明具体到了时分秒,怎么不显示?」
|
||||
// → 行程行改用 nodeSpan()(精确到时分),**不再**用只到日期的 formatSpan()。
|
||||
// formatSpan 那个精度是给"旅程"(容器)用的。
|
||||
$span = \App\Trip::nodeSpan($n['starts_at'] ?? null, $n['ends_at'] ?? null);
|
||||
$mode = (string)($n['travel_mode'] ?? '');
|
||||
$spent = (int)($n['spent_cents'] ?? 0);
|
||||
?>
|
||||
@@ -194,9 +200,6 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<?php if ((int)($n['revision_n'] ?? 0) > 0): ?>
|
||||
<span class="rev-badge" title="已修正过多次">已修正 <?= (int)$n['revision_n'] ?> 次</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['place'])): ?>
|
||||
<span class="node-place">📍 <?= e((string)$n['place']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($mode !== ''): ?>
|
||||
<span class="node-mode"><?= e(\App\Trip::modeIcon($mode)) ?> <?= e(\App\Trip::modeWord($mode)) ?></span>
|
||||
<?php endif; ?>
|
||||
@@ -209,14 +212,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 交通细节(TripLog 同款):只显示**填过的**那几个,没填就不占地方 */ ?>
|
||||
<?php /* 交通细节:**只保留**「线路名 / 车次航班号 / 承运方」。
|
||||
⚠️ 第 17 批 第 3 条明确「座位什么的不记录了」→
|
||||
座位 / 车厢、登机口 / 站台**不再出现在这里**
|
||||
(数据库那两列还留着老数据,只是不再展示)。 */ ?>
|
||||
<?php
|
||||
$traffic = [];
|
||||
if (!empty($n['line_name'])) { $traffic[] = (string)$n['line_name']; }
|
||||
if (!empty($n['trip_no'])) { $traffic[] = (string)$n['trip_no']; }
|
||||
if (!empty($n['carrier'])) { $traffic[] = (string)$n['carrier']; }
|
||||
if (!empty($n['seat_info'])) { $traffic[] = '座位 ' . (string)$n['seat_info']; }
|
||||
if (!empty($n['gate'])) { $traffic[] = '登机口/站台 ' . (string)$n['gate']; }
|
||||
?>
|
||||
<?php if ($traffic): ?>
|
||||
<div class="node-traffic">
|
||||
@@ -224,9 +228,27 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* 出发地 → 到达地(第 17 批 第 3 条要记详细地址;
|
||||
这里显示地点名,详细地址放 title 里悬停可见 + 带导航链接)。 */ ?>
|
||||
<?php if (!empty($n['from_place']) || !empty($n['to_place'])): ?>
|
||||
<div class="node-route-line">
|
||||
<?= e((string)($n['from_place'] ?? '—')) ?> <span class="arrow">→</span> <?= e((string)($n['to_place'] ?? '—')) ?>
|
||||
<?php
|
||||
$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 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'] ?? '—')) ?>
|
||||
<?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'] ?? '—')) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -238,15 +260,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<div class="node-spent">💰 这一段花了 <?= e(\App\Expense::money($spent)) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ⚠️ 第 17 批 第 3 条:「给参与人标注角色这个功能去掉,不要了。」
|
||||
所以这里**不再渲染** role_tag(数据库里老数据的角色还在,只是不显示)。 */ ?>
|
||||
<?php if (!empty($n['members'])): ?>
|
||||
<div class="trip-people">
|
||||
<?php foreach ($n['members'] as $mm): ?>
|
||||
<span class="node-member">
|
||||
<?= person_chip($mm) ?>
|
||||
<?php if (!empty($mm['role_tag'])): ?>
|
||||
<span class="node-role"><?= e((string)$mm['role_tag']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -272,7 +291,7 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<?php foreach ($n['revisions'] as $rv): ?>
|
||||
<tr>
|
||||
<td><?= (int)$rv['seq'] === 1 ? '原计划' : '第 ' . ((int)$rv['seq'] - 1) . ' 次修正' ?></td>
|
||||
<td class="small"><?= e(\App\Trip::formatSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
|
||||
<td class="small"><?= e(\App\Trip::nodeSpan($rv['starts_at'] ?? null, $rv['ends_at'] ?? null)) ?></td>
|
||||
<td><?= e((string)($rv['place'] ?? '')) ?></td>
|
||||
<td class="small"><?= e((string)($rv['detail'] ?? '')) ?></td>
|
||||
</tr>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* 车辆管理(v24 第 16 批 用户第 8 条)。
|
||||
* 车辆管理(v24 第 16 批 用户第 8 条;第 17 批 第 3 条改)。
|
||||
*
|
||||
* 用户原话:「注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加
|
||||
* 那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
|
||||
* 用户原话(第 16 批 第 8 条):
|
||||
* 「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
|
||||
* 注意,我们在财务中,已经把车辆添加进去了…但是可以添加其他的车辆,
|
||||
* 比如租的车,借别人的车。」
|
||||
*
|
||||
* 所以这一页有两块:
|
||||
* ① **财务里已登记的车**(只读)—— 只列出来,不给改不给删(那是财务的数据);
|
||||
* ② **出行用的车**(本表)—— 自家 / 租的 / 借的,可增删改。
|
||||
* 用户原话(**第 17 批 第 3 条 —— 本次,推翻上一批的做法**):
|
||||
* 「车辆下拉框,**不需要出现什么财务里登记的车**,只把车辆列出来就行了。」
|
||||
*
|
||||
* ⚠️ 所以这一页现在**只有一块**:「出行用的车」(自家 / 租的 / 借的)。
|
||||
* 原来那块「财务里已登记的车」整块删掉了 —— 财务里的 kind=car 是**资产账目**
|
||||
* ("这台车值多少钱"),它没有车牌车型,混进选车列表只会让人发懵。
|
||||
* `Vehicle::fromLedger()` 方法保留在模型层(将来按车统计油费/保养还要用它
|
||||
* 跟 `vehicles.ledger_id` 对上),但**不再被任何界面调用**。
|
||||
*
|
||||
* @param array $ledger Vehicle::fromLedger() 的结果(只读)
|
||||
* @param array $mine Vehicle::all() 的结果
|
||||
*/
|
||||
$canWrite = \App\Auth::canWrite();
|
||||
$ledger = $ledger ?? [];
|
||||
$mine = $mine ?? [];
|
||||
$back = url('trips');
|
||||
?>
|
||||
@@ -27,39 +31,10 @@ $back = url('trips');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ---- ① 来自财务的车(只读引用)---- */ ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
财务里已登记的车
|
||||
<span class="hint">只读 —— 要改请到财务模块</span>
|
||||
</h2>
|
||||
<?php if (!$ledger): ?>
|
||||
<p class="empty-hint">
|
||||
财务里还没有登记车子。到「财务 → 记一笔」里把车子作为一项资产记下来,
|
||||
这里就会自动出现(<b>不必在这里重复添加</b>)。
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<ul class="veh-list">
|
||||
<?php foreach ($ledger as $v): ?>
|
||||
<li class="veh-row veh-ro">
|
||||
<span class="veh-ico">🚗</span>
|
||||
<span class="veh-name"><?= e((string)$v['name']) ?></span>
|
||||
<span class="veh-tag">来自财务</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="small muted">
|
||||
这些是财务里的**资产记录**(有一台车值多少钱),不一定有车牌与车型。
|
||||
出行要选车时它们也会出现在下拉里,标着「来自财务」。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ---- ② 出行用的车(本表)---- */ ?>
|
||||
<div class="card card-pad">
|
||||
<h2 class="cinema-h2">
|
||||
出行用的车
|
||||
<span class="hint">自家车可以录一台方便选;租的车、借的车录在这里</span>
|
||||
<span class="hint">自家车录一台方便选;租的车、借的车也录在这里</span>
|
||||
</h2>
|
||||
|
||||
<?php if ($mine): ?>
|
||||
|
||||
Reference in New Issue
Block a user