Compare commits
29
Commits
f92a98a3a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d745b65b2 | ||
|
|
7ae595c8c2 | ||
|
|
90973bb71c | ||
|
|
69c47c3c43 | ||
|
|
47a0027ef7 | ||
|
|
50286e5327 | ||
|
|
255fb8a240 | ||
|
|
4d04490f7f | ||
|
|
33940fa730 | ||
|
|
8e48d0b1b2 | ||
|
|
cb846d378f | ||
|
|
11aa7c6ca5 | ||
|
|
aac7dd5974 | ||
|
|
20ba36ad62 | ||
|
|
9ab7d40a3b | ||
|
|
7a108396ab | ||
|
|
836f6f1d2f | ||
|
|
a43950069d | ||
|
|
870a63df5a | ||
|
|
52946e9c35 | ||
|
|
e015181fa3 | ||
|
|
403ecd9dea | ||
|
|
306f5a61cb | ||
|
|
bee1d077d8 | ||
|
|
2c44318a51 | ||
|
|
d6af211a8e | ||
|
|
3c9fb22def | ||
|
|
f58a128816 | ||
|
|
a1140437a1 |
+77
-1
@@ -282,6 +282,9 @@ final class Anniversary
|
||||
'is_leap' => 0,
|
||||
'person_id' => $pid,
|
||||
'person_name' => (string)($p['name'] ?? ''),
|
||||
/* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */
|
||||
'avatar' => (string)($p['avatar'] ?? ''),
|
||||
'avatar_color' => (string)($p['avatar_color'] ?? ''),
|
||||
'source' => 'birthday_solar',
|
||||
'born' => $solar,
|
||||
];
|
||||
@@ -299,6 +302,8 @@ final class Anniversary
|
||||
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
|
||||
'person_id' => $pid,
|
||||
'person_name' => (string)($p['name'] ?? ''),
|
||||
'avatar' => (string)($p['avatar'] ?? ''),
|
||||
'avatar_color' => (string)($p['avatar_color'] ?? ''),
|
||||
'source' => 'birthday_lunar',
|
||||
'born' => $lunar,
|
||||
];
|
||||
@@ -586,7 +591,24 @@ final class Anniversary
|
||||
public static function occurrences(int $familyId, int $viewerId, int $year): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::all($familyId, $viewerId) as $a) {
|
||||
$all = self::all($familyId, $viewerId);
|
||||
|
||||
/* 🔴🔴 #91d(用户 2026-11-05):「你去查一下这个领证纪念日到底关联了几个人,
|
||||
数据库里。」
|
||||
|
||||
查库结果:`anniversaries #2` 的**老列** `person_id = 1`(只能存一个),
|
||||
但 `anniversary_people` 里有 **2 人**(杨永全 + 洪博为)。
|
||||
端上编辑框只读老列 ⇒ 只带出 1 个人,另一个被静默丢掉。
|
||||
|
||||
⇒ 在这里一次性把多对多关系查出来。
|
||||
⚠️ **一次查全表**而不是逐条查:`peopleMap()` 正是为此写的
|
||||
(`split()` 早就这么用了;这里漏了)。
|
||||
⚠️ 不能直接用老列兜底就完事 —— 老列是升级前的遗留字段,
|
||||
两边不一致时**以多对多表为准**(用户在新版界面上改的是那张表)。
|
||||
⚠️ 多对多表里没有行时才退回老列(升级过渡期还没回填的条目)。 */
|
||||
$pmap = self::peopleMap(array_column($all, 'id'));
|
||||
|
||||
foreach ($all as $a) {
|
||||
$repeat = (string)($a['repeat_type'] ?? 'solar');
|
||||
$m = (int)($a['month'] ?? 0);
|
||||
$d = (int)($a['day'] ?? 0);
|
||||
@@ -644,6 +666,60 @@ final class Anniversary
|
||||
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
|
||||
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
|
||||
'person_id' => (int)($a['person_id'] ?? 0),
|
||||
/* #91d:**完整关联人列表**(多对多表为准,空则退回老列)。
|
||||
端上编辑纪念日要带出全部关联人、只读卡要显示"关联了谁"。 */
|
||||
'person_ids' => (function () use ($pmap, $a): array {
|
||||
$aid = (int)($a['id'] ?? 0);
|
||||
if ($aid > 0 && !empty($pmap[$aid])) {
|
||||
return array_map('intval', $pmap[$aid]);
|
||||
}
|
||||
$one = (int)($a['person_id'] ?? 0);
|
||||
return $one > 0 ? [$one] : [];
|
||||
})(),
|
||||
/* 🔴 #87(用户 2026-11-05):「如果这个纪念日来自生日…
|
||||
点击之后,显示这个人的头像、姓名,以及生日的『日』和日期就可以了。」
|
||||
|
||||
⇒ 端上要画头像,所以这里必须带出来。
|
||||
原来 `birthdays()` 算出来的 `person_name` 在这里**被丢掉了**
|
||||
—— `occurrences()` 逐个字段挑着抄,漏了它。
|
||||
⇒ 一并补 `person_name` + `avatar` + `avatar_color`。
|
||||
`avatar` 是 key(如 `avatars/2026xxx.jpg`),
|
||||
端上自己拼完整 URL(与任务负责人头像同一口径)。
|
||||
⚠️ `all()` 里自建纪念日那批的 `person_name` 是 `''`
|
||||
(自建纪念日可能关联多人、也可能不关联人),
|
||||
这里原样透传即可,端上"名字为空就不画头像"。 */
|
||||
'person_name' => (string)($a['person_name'] ?? ''),
|
||||
'avatar' => (string)($a['avatar'] ?? ''),
|
||||
'avatar_color' => (string)($a['avatar_color'] ?? ''),
|
||||
/* 「日」—— 用户明确要"生日的『日』"。
|
||||
直接给整数,端上做"大字显示"(如「17」)。
|
||||
⚠️ 用 `$a['day']` 而不是从 `$date` 里截:
|
||||
`$a['day']` 是**用户填的那一天**(农历生日时是农历日),
|
||||
`$date` 是换算出来的公历日期 —— 两者在农历生日上不同,
|
||||
用户要看的是"生日那天"(农历初五就该显示 5)。 */
|
||||
'day' => (int)($a['day'] ?? 0),
|
||||
'month' => (int)($a['month'] ?? 0),
|
||||
/* 🔴 #88(用户 2026-11-05):卡片右侧要显示两种日期写法 ——
|
||||
「如果是农历的话,就要写上农历十月二日。下面还要写上阳历:
|
||||
换算完之后,今年阳历是多少?比如11月10日」
|
||||
|
||||
⇒ 服务端直接给**成品文字**,端上不自己拼:
|
||||
· `lunar_label` = 「十月二日」/「闰四月初五」(农历生日才有;其余空)
|
||||
· `solar_md` = 「11月10日」(今年实际发生那天的公历月日)
|
||||
|
||||
⚠️ 农历文字为什么不在端上拼:`Lunar` 有完整的月名/日名对照表
|
||||
(正月/冬月/腊月、初一/廿三…),端上再抄一份必然漂移,
|
||||
而且闰月(闰四月)这种特例得一起处理。
|
||||
⚠️ `solar_md` 用 `$date`(今年换算后的实际日期)而不是 `$a['month']/$a['day']`:
|
||||
农历生日的 month/day 是**农历**月日,直接拼出来会是错的公历。
|
||||
⚠️ 农历生日时 `lunar_label` 用 `$a['month']/$a['day']`(才是农历月日);
|
||||
公历生日时留空 —— 那时"农历"这一行没有意义。 */
|
||||
'lunar_label' => ($repeat === 'lunar' && $m >= 1 && $d >= 1)
|
||||
? (\App\Lunar::monthName($m, !empty($a['is_leap'])) . \App\Lunar::dayName($d))
|
||||
: '',
|
||||
'solar_md' => (strlen((string)$date) >= 10)
|
||||
? ((int)substr((string)$date, 5, 2)) . '月' . ((int)substr((string)$date, 8, 2)) . '日'
|
||||
: '',
|
||||
'source' => (string)($a['source'] ?? 'custom'),
|
||||
'note' => (string)($a['note'] ?? ''),
|
||||
'id' => (int)($a['id'] ?? 0),
|
||||
|
||||
+363
-4
@@ -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();
|
||||
@@ -1759,9 +1821,39 @@ final class ApiHandler
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
Api::requireWrite();
|
||||
/* 🔴 #77g(用户 2026-11-05):「留言是可以需要添加附件的。」
|
||||
|
||||
⇒ 支持 **multipart/form-data**(带 `file`)与原来的
|
||||
**JSON / 表单**(只有 content)两种提交方式。
|
||||
前端有附件就走 multipart,没附件走原来的路径 —— 不破坏旧调用方。
|
||||
|
||||
⚠️ **允许"只发附件不写字"**:拍张照片直接发是常见用法。
|
||||
网页端 `tasks/comment_add` 早有同样的处理(占位文案「(附件)」),
|
||||
这里与它保持同一口径。
|
||||
⚠️ 附件走 `Attachment::save()`,`kind` 用 `'task_comment'`
|
||||
—— 与网页端 `attach_save_files(..., 'task_comment', ...)`
|
||||
是同一个归属类型,否则同一条留言的附件在两端互相看不见。
|
||||
⚠️ 一个评论先建(拿到 cid),再存附件 —— 顺序不能反:
|
||||
`Attachment::save()` 要 `owner_id`,而 cid 是插入后才有的。 */
|
||||
$txt = Api::str('content', '', 4000);
|
||||
if ($txt === '') { Api::fail('评论不能是空的。', 'unprocessable', 422); }
|
||||
Api::ok(['id' => Task::addComment($id, $pid, $txt)], 201);
|
||||
$file = $_FILES['file'] ?? null;
|
||||
$hasFile = is_array($file)
|
||||
&& trim((string)($file['name'] ?? '')) !== ''
|
||||
&& (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;
|
||||
if ($txt === '' && !$hasFile) {
|
||||
Api::fail('评论不能是空的。', 'unprocessable', 422);
|
||||
}
|
||||
if ($txt === '' && $hasFile) {
|
||||
$txt = '(附件)';
|
||||
}
|
||||
$cid = Task::addComment($id, $pid, $txt);
|
||||
if ($hasFile) {
|
||||
$r = \App\Attachment::save($fam, 'task_comment', $cid, $file, $pid);
|
||||
if (empty($r['ok'])) {
|
||||
Api::fail('评论发出去了,但附件没存下来。', 'unprocessable', 422);
|
||||
}
|
||||
}
|
||||
Api::ok(['id' => $cid], 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,7 +1943,15 @@ final class ApiHandler
|
||||
'repeat_rule' => Api::str('repeat_rule', (string)($t['repeat_rule'] ?? '')),
|
||||
'parent_id' => Api::int('parent_id', (int)($t['parent_id'] ?? 0)),
|
||||
'assignee_id' => Api::int('assignee_id', (int)($t['assignee_id'] ?? 0)),
|
||||
'category_id' => Api::int('category_id', (int)($t['category_id'] ?? 0)),
|
||||
/* 🔴 #84:原来写的是 `Api::int('category_id', (int)($t['category_id'] ?? 0))`
|
||||
—— 但 `Api::int()` 是**读请求参数**的,它先看 $_REQUEST 里有没有
|
||||
`category_id`,没有才用第二个参数当默认值。
|
||||
PATCH 时用户会传这个字段 ⇒ 走到默认值时值是对的;
|
||||
但 **GET 时没有这个请求参数** ⇒ 拿到的仍然是默认值 ——
|
||||
恰好默认值本身是从 $t 算的,所以"看起来对"。
|
||||
真正的问题是读起来极容易误判成"已经取到表里的值了"。
|
||||
⇒ 直接写表里的值,不绕 Api::int。 */
|
||||
'category_id' => (int)($t['category_id'] ?? 0),
|
||||
'board_id' => Api::int('board_id', (int)($t['board_id'] ?? 0)),
|
||||
'status' => (string)$t['status'], // 状态只走 /status 子动作
|
||||
]);
|
||||
@@ -2463,6 +2563,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 +2663,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));
|
||||
}
|
||||
|
||||
+43
-1
@@ -83,8 +83,45 @@ final class Attachment
|
||||
}
|
||||
$key = (string)$up['file'];
|
||||
} else {
|
||||
/* 🔴🔴 #57(用户 2026-11-05:「以后所有视频全部转成 H.264」)
|
||||
视频先转码再存。
|
||||
|
||||
为什么必须转:用户的视频是手机拍的 H.265/HEVC,
|
||||
Chrome/Firefox **无法解码**(不存在能解码 HEVC 的前端 JS 库)。
|
||||
只能在服务端转成 H.264。
|
||||
|
||||
⚠️ `needsTranscode()` 先判:已经符合 H.264 + yuv420p 的**不重复转**
|
||||
—— 用户传合规视频时不该白等几十秒。
|
||||
⚠️ **转码失败不阻断上传**:原样存。转码可能因磁盘满/编码异常失败,
|
||||
不该因此让用户传不了;原文件还能下载后用本地播放器看。
|
||||
⚠️ 转完要改 `$mime` / `$ext`,否则入库的 MIME 还是 video/quicktime
|
||||
之类,前端又判断不出来。
|
||||
⚠️ 转码产物是临时文件,**必须清掉**(撒了会塞满 /tmp)。 */
|
||||
$transcodeNote = '';
|
||||
if (Video::isVideo($mime) && Video::needsTranscode($tmp)) {
|
||||
$tr = Video::toH264($tmp);
|
||||
if (!empty($tr['ok'])) {
|
||||
$newPath = (string)$tr['path'];
|
||||
@unlink($tmp); // 原文件不要了
|
||||
$tmp = $newPath;
|
||||
$mime = 'video/mp4';
|
||||
$ext = 'mp4';
|
||||
$transcodeNote = (string)($tr['note'] ?? '');
|
||||
$size = (int)@filesize($tmp);
|
||||
} else {
|
||||
/* 转码失败:保留原文件,记一笔原因(不阻断上传) */
|
||||
$transcodeNote = '转码失败,已保存原文件:' . (string)($tr['error'] ?? '');
|
||||
error_log('[Attachment] 视频转码失败 target=' . $type . '/' . $targetId
|
||||
. ' err=' . (string)($tr['error'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : '');
|
||||
$res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream');
|
||||
/* 转码产物用完即删 —— 它是临时文件,不能留在 /tmp */
|
||||
if ($transcodeNote !== '' && strpos($tmp, '.h264.mp4') !== false) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
if (empty($res['ok'])) {
|
||||
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
|
||||
}
|
||||
@@ -104,7 +141,12 @@ final class Attachment
|
||||
self::nextPosition($type, $targetId),
|
||||
$uploaderId > 0 ? $uploaderId : null,
|
||||
]);
|
||||
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
|
||||
return [
|
||||
'ok' => true,
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
/* 转码说明(空串 = 没转,不需要提示) */
|
||||
'note' => $transcodeNote ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/** 删一个附件(连存储对象一起) */
|
||||
|
||||
+39
-8
@@ -4,12 +4,12 @@
|
||||
*
|
||||
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
|
||||
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
|
||||
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** ——
|
||||
* ② **观影与旅程是每人默认的两本**,名字就叫「观影」「旅程」,内容**从 events 派生** ——
|
||||
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
|
||||
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
|
||||
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
|
||||
*
|
||||
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务,
|
||||
* 日历其实是"综合视图":自定义日程 + 观影 + 旅程 + 有截止时间的任务,
|
||||
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,15 @@ final class Calendar
|
||||
custom 排最前,所以它天然就是"新建日程"的默认本。 */
|
||||
'custom' => ['我的日程', '#4A7C82'],
|
||||
'movie' => ['观影', '#7A4E8C'],
|
||||
'trip' => ['旅行', '#3F7D5A'],
|
||||
/* 🔴 #71(用户 2026-11-05):「旅程的日历本同步到日历本,它的日历本名字
|
||||
就叫"旅程",观影就叫"观影"。我看现在"旅程"叫"旅行",这个不对,应该改过来。」
|
||||
|
||||
⇒ 「旅行」→「旅程」。模块本身一直叫"旅程"(页面/菜单/接口全是旅程),
|
||||
只有这本默认日历的名字是"旅行" —— 用户进日历看到"旅行"会觉得是另一个东西。
|
||||
⚠️ 改这里只影响**新家庭首次 seed**;已有家庭的库里数据要单独 UPDATE
|
||||
(见同批部署的 SQL)。
|
||||
⚠️ `movie` 那条**不要动** —— 用户明确说"观影就叫观影",本来就是对的。 */
|
||||
'trip' => ['旅程', '#3F7D5A'],
|
||||
'lunar' => ['农历', '#8C6D3F'],
|
||||
'holiday' => ['节假日', '#B0342C'],
|
||||
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
|
||||
@@ -1636,6 +1644,9 @@ final class Calendar
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
|
||||
n.node_type, n.status AS node_status, n.stage AS node_stage,
|
||||
n.travel_mode,
|
||||
n.from_place, n.to_place,
|
||||
n.from_lng, n.from_lat, n.to_lng, n.to_lat,
|
||||
e.id AS event_id, e.title AS trip_title
|
||||
FROM trip_nodes n
|
||||
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
||||
@@ -1661,11 +1672,19 @@ final class Calendar
|
||||
'cal' => (int)$c['id'],
|
||||
'color' => (string)$c['color'],
|
||||
'calname' => (string)$c['name'],
|
||||
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。
|
||||
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。
|
||||
'title' => Trip::typeIcon((string)$n['node_type'])
|
||||
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ')
|
||||
. (string)$n['node_title'],
|
||||
/* 🔴 #67(用户 2026-11-05,两条):
|
||||
① 「这个图标应该来自里面具体的交通工具,也就是说,如果是火车,
|
||||
就应该显示火车。」
|
||||
⇒ 原来只按 `node_type` 取,`typeIcon('move')` 恒为 🚗 ——
|
||||
「赶路」永远显示汽车,哪怕实际坐的是火车。
|
||||
改用 `leadIcon()`(出行方式优先、节点类型兜底),
|
||||
它正是旅行详情页行程卡左侧用的同一个方法。
|
||||
② 「后面这个对号是什么意思?…我感觉没必要,直接把这个对号去掉。」
|
||||
⇒ 删掉原来插在标题里的 `' ✓ '`。 */
|
||||
'title' => Trip::leadIcon((string)$n['node_type'],
|
||||
(string)($n['travel_mode'] ?? ''),
|
||||
$familyId)
|
||||
. ' ' . (string)$n['node_title'],
|
||||
'date' => $day,
|
||||
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
|
||||
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
|
||||
@@ -1673,6 +1692,18 @@ final class Calendar
|
||||
'all_day' => $first && $tm === '00:00',
|
||||
'kind' => 'trip',
|
||||
'ref_id' => (int)$n['node_id'],
|
||||
/* 🔴 #67(用户:「如果行程里面有地址,也就是有出发地和目的地,
|
||||
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」)
|
||||
⇒ 原来这一段的 SQL **完全没查** from_* / to_*,
|
||||
日历里只剩一个标题,用户看不出这段是从哪到哪。
|
||||
带坐标是因为导航要用:没有 lat/lng 只能搜地名,
|
||||
同名的地点会搜错城市(这个坑在旅程模块已经踩过一次)。 */
|
||||
'from_place' => (string)($n['from_place'] ?? ''),
|
||||
'to_place' => (string)($n['to_place'] ?? ''),
|
||||
'from_lng' => (string)($n['from_lng'] ?? ''),
|
||||
'from_lat' => (string)($n['from_lat'] ?? ''),
|
||||
'to_lng' => (string)($n['to_lng'] ?? ''),
|
||||
'to_lat' => (string)($n['to_lat'] ?? ''),
|
||||
'href' => url('trips/show', ['id' => (int)$n['event_id']])
|
||||
. '#node-' . (int)$n['node_id'],
|
||||
];
|
||||
|
||||
+225
@@ -474,6 +474,101 @@ final class Geo
|
||||
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
|
||||
* 不是靠猜。
|
||||
*/
|
||||
/**
|
||||
* 把任意「行政区划串」归一化为**纯市名**(「青岛市」)。
|
||||
*
|
||||
* 🔴 2026-11-05 新增。用户原话:
|
||||
* 「它那个城市原来我说的是指定城市就可以,比如北京市、青岛市。
|
||||
* 现在弄得很乱,什么省也带上了,市也带上了,区甚至都带上了,
|
||||
* 这没必要,直接城市就可以。」
|
||||
*
|
||||
* ## 为什么必须在服务端做
|
||||
*
|
||||
* 前端(网页 `addr_suggest.js`、鸿蒙端)都可能传进「山东省青岛市市南区」,
|
||||
* 这是历史行为。靠"前端自觉"不可靠:
|
||||
* · 老版本 App 还在用;
|
||||
* · 第三方/脚本调用不经过我们的前端;
|
||||
* · 老数据已经脏了。
|
||||
* ⇒ 服务端统一归一化,前端传什么都剥成市名。**这是唯一的真防线。**
|
||||
*
|
||||
* ## 取值顺序
|
||||
*
|
||||
* ① 已经就是纯市名(「青岛市」)→ 原样返回
|
||||
* ② 含「XX市」→ 取**第一个**(中文地址是 省→市→区 层级顺序)
|
||||
* ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市」会得到县级市
|
||||
* 「五大连池市」;「山东省青岛市市南区」的"市南区"里还有"市"字
|
||||
* ③ 直辖市不带"市"字(「北京朝阳区」)→ 开头匹配四直辖市
|
||||
* ④ 都没有 → 返回空串(**不猜**)
|
||||
*
|
||||
* @param string $raw 可能带省/区/空格/「·」分隔的行政区划串
|
||||
* @return string 纯市名;认不出返回空串
|
||||
*/
|
||||
public static function cityOnly(string $raw): string
|
||||
{
|
||||
$s = trim($raw);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
/* 已经是纯市名(最常见的正常情况)→ 直接返回,不做正则 */
|
||||
if (mb_substr($s, -1, 1, 'UTF-8') === '市' && mb_strpos($s, ' ') === false
|
||||
&& mb_strpos($s, '·') === false && mb_strpos($s, '省') === false
|
||||
&& mb_strpos($s, '区') === false && mb_strpos($s, '县') === false) {
|
||||
return $s;
|
||||
}
|
||||
/* 带「·」的(老格式「山东·青岛」)→ 取后半段再走一遍 */
|
||||
$dot = mb_strpos($s, '·');
|
||||
if ($dot !== false) {
|
||||
$s = trim(mb_substr($s, $dot + 1, null, 'UTF-8'));
|
||||
if ($s !== '') {
|
||||
/* 「青岛」这种不带市的也要认 —— 补上市字再返回 */
|
||||
if (mb_substr($s, -1, 1, 'UTF-8') === '市') {
|
||||
return $s;
|
||||
}
|
||||
if (!preg_match('/[省区县旗盟]/u', $s)) {
|
||||
return $s . '市';
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 空格分隔的(「山东省 青岛市 市南区」)→ 逐段找第一个「XX市」 */
|
||||
$segs = preg_split('/\s+/u', $s) ?: [];
|
||||
foreach ($segs as $seg) {
|
||||
$got = self::firstCityWord($seg);
|
||||
if ($got !== '') {
|
||||
return $got;
|
||||
}
|
||||
}
|
||||
/* 整串里找第一个「XX市」 */
|
||||
$got = self::firstCityWord($s);
|
||||
if ($got !== '') {
|
||||
return $got;
|
||||
}
|
||||
/* 直辖市不带"市"字(「北京朝阳区」) */
|
||||
foreach (self::MUNICIPALITIES as $m) {
|
||||
$short = rtrim($m, '市');
|
||||
if ($short !== '' && mb_strpos($s, $short) === 0) {
|
||||
return $m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一个片段里取**第一个**「XX市」,XX 中不允许出现层级字眼。
|
||||
*
|
||||
* ⚠️ `[^省市县区旗盟]` 这个字符类不能省:
|
||||
* 否则「山东省青岛市」会匹配到「山东省市」(把"山东省"当成了城市名前缀)。
|
||||
*/
|
||||
private static function firstCityWord(string $s): string
|
||||
{
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/([^\s省市县区旗盟的了吧呢啊个这那有字方地]{2,6})市/u', $s, $m)) {
|
||||
return $m[1] . '市';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function cityFromText(string $text): string
|
||||
{
|
||||
$t = trim($text);
|
||||
@@ -636,6 +731,136 @@ final class Geo
|
||||
}
|
||||
|
||||
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
|
||||
/**
|
||||
* 地址联想(高德输入提示)。
|
||||
*
|
||||
* 🔴 2026-11-03 新增。鸿蒙端 Map Kit 报 1002600003(AGC 认证失败),
|
||||
* 而 AGC 那条链路需要开地图服务 + 重新生成 Profile,可能等 24 小时。
|
||||
* ⇒ 加这个接口:端上走我们的服务器,服务器用**高德 Web 服务 key** 调 inputtips。
|
||||
*
|
||||
* 好处(不只是绕过 AGC):
|
||||
* · **数据源与网页端完全一致** —— 网页 `addr_suggest.js` 也用高德,
|
||||
* 两端选中的地址是同一套 POI,不会再出现"网页能搜到、手机搜不到";
|
||||
* · inputtips 直接给 `district`(省市区)+ `address`(门牌)+ `location`(坐标),
|
||||
* 地址天生完整,不用像 Map Kit 那样自己拼。
|
||||
*
|
||||
* @param string $kw 关键词
|
||||
* @param int $limit 最多几条
|
||||
* @return array<int,array{name:string,detail:string,district:string,address:string,lng:string,lat:string}>
|
||||
* **任何失败都返回空数组**,不抛错(联想挂掉不能拖垮"记一条日程")
|
||||
*/
|
||||
public static function suggest(string $kw, int $limit = 8): array
|
||||
{
|
||||
$q = trim($kw);
|
||||
if ($q === '' || mb_strlen($q, 'UTF-8') < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$key = self::webKey();
|
||||
if ($key === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = 'https://restapi.amap.com/v3/assistant/inputtips'
|
||||
. '?key=' . urlencode($key)
|
||||
. '&keywords=' . urlencode($q)
|
||||
. '&datatype=all'
|
||||
. '&citylimit=false';
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 6,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err !== '' || !is_string($body) || $body === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$j = json_decode($body, true);
|
||||
if (!is_array($j) || (string)($j['status'] ?? '') !== '1' || empty($j['tips'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($j['tips'] as $t) {
|
||||
/* ⚠️ 高德的字段类型**不保证是字符串**:
|
||||
实测 `district`/`address`/`location` 在部分条目里是**数组**(空数组),
|
||||
直接当字符串用会触发 "Array to string conversion" 警告。
|
||||
⇒ 统一过一遍 toStr()。 */
|
||||
$name = self::toStr($t['name'] ?? '');
|
||||
$dist = self::toStr($t['district'] ?? '');
|
||||
$addr = self::toStr($t['address'] ?? '');
|
||||
$loc = self::toStr($t['location'] ?? '');
|
||||
|
||||
/* ⚠️ 有些条目是"纯行政区划"(如 name=招商银行、district=空、address=空),
|
||||
没有地址也没有坐标 ⇒ 面板上就是一行空白,跳过。 */
|
||||
if ($name === '' || ($addr === '' && $dist === '' && $loc === '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lng = '';
|
||||
$lat = '';
|
||||
if ($loc !== '' && strpos($loc, ',') !== false) {
|
||||
$seg = explode(',', $loc);
|
||||
if (count($seg) === 2) {
|
||||
// 高德给的是 "经度,纬度"
|
||||
$lng = (string)$seg[0];
|
||||
$lat = (string)$seg[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 详细地址 = 省市区 + 门牌(用户要的"参考信息")
|
||||
$detail = $dist;
|
||||
if ($addr !== '') {
|
||||
$detail = $detail === '' ? $addr : ($detail . $addr);
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'detail' => $detail,
|
||||
'district' => $dist,
|
||||
'address' => $detail,
|
||||
'lng' => $lng,
|
||||
'lat' => $lat,
|
||||
];
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把高德返回的值统一成字符串。
|
||||
*
|
||||
* 高德的 PHP 侧字段类型不稳定(字符串 / 空数组 / 数组都见过),
|
||||
* 直接 `(string)` 会触发 "Array to string conversion" 警告并得到 "Array"。
|
||||
*/
|
||||
private static function toStr($v): string
|
||||
{
|
||||
if (is_string($v)) {
|
||||
return trim($v);
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
return (string)$v;
|
||||
}
|
||||
// 数组/对象/null 一律当空(高德用 [] 表示"没这个字段")
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 端上地址联想用的 key(Web 服务类型) */
|
||||
public static function webKey(): string
|
||||
{
|
||||
$a = fb_config('amap_web');
|
||||
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
|
||||
}
|
||||
|
||||
public static function apiKey(): string
|
||||
{
|
||||
$a = fb_config('amap');
|
||||
|
||||
@@ -1542,6 +1542,22 @@ SQL,
|
||||
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
|
||||
self::addColumn($db, 'trip_nodes', 'revision_n',
|
||||
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
|
||||
|
||||
/* 🔴 #48(2026-11-05):**真实开始 / 结束时间**。
|
||||
用户:「我计划今天 8:00 到 10:00 开车去公园,但我可能出门晚了,
|
||||
8:10 或 8:30 才点『开始』按钮。这时候就要记录住 8:30 这个时间,
|
||||
因为 8:30 会成为实际行程的开始时间。也就是说,
|
||||
**原来计划的时间和现在真正开始的时间都要记录**。」
|
||||
|
||||
⚠️ 不能复用 `starts_at` / `ends_at`(那是**计划**时间),
|
||||
改掉等于把计划丢了。两者并存才能"计划 8:00–10:00 / 实际 8:30–10:30"。
|
||||
⚠️ 列名带 `actual_` 前缀,与 `starts_at` 形成清晰对照。 */
|
||||
self::addColumn($db, 'trip_nodes', 'actual_started_at',
|
||||
"ADD COLUMN `actual_started_at` DATETIME DEFAULT NULL
|
||||
COMMENT '真实开始时间(点「开始」那一刻;NULL=还没开始)' AFTER `ends_at`");
|
||||
self::addColumn($db, 'trip_nodes', 'actual_ended_at',
|
||||
"ADD COLUMN `actual_ended_at` DATETIME DEFAULT NULL
|
||||
COMMENT '真实结束时间(点「完成」那一刻;NULL=还没完成)' AFTER `actual_started_at`");
|
||||
// ② 建修正历史表(老库没有)
|
||||
self::tryStep('建行程修正历史表', static function () use ($db) {
|
||||
$db->exec(<<<'SQL'
|
||||
|
||||
@@ -168,13 +168,19 @@ final class OssDriver implements StorageDriver
|
||||
|
||||
// ---------------- 签名 ----------------
|
||||
|
||||
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
|
||||
/**
|
||||
* @param string $subresource 子资源串(如 `?x-oss-process=image/resize,w_400`),
|
||||
* **必须计入 CanonicalizedResource**,否则 403。
|
||||
*/
|
||||
private function authorization(string $method, string $key, string $contentType, string $date,
|
||||
array $cred, string $subresource = ''): string
|
||||
{
|
||||
$canonicalHeaders = '';
|
||||
if ((string)($cred['token'] ?? '') !== '') {
|
||||
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
|
||||
}
|
||||
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
|
||||
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/')
|
||||
. $subresource;
|
||||
$stringToSign = $method . "\n"
|
||||
. "\n" // Content-MD5(未使用)
|
||||
. $contentType . "\n"
|
||||
@@ -200,12 +206,13 @@ final class OssDriver implements StorageDriver
|
||||
}
|
||||
|
||||
/** 读请求刻意不发 Content-Type,签名串里传空串 */
|
||||
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array
|
||||
private function readHeaders(string $key, array $cred, array $extra = [],
|
||||
string $method = 'GET', string $subresource = ''): array
|
||||
{
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
$h = [
|
||||
'Date: ' . $date,
|
||||
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
||||
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred, $subresource),
|
||||
];
|
||||
if ((string)($cred['token'] ?? '') !== '') {
|
||||
$h[] = 'x-oss-security-token: ' . $cred['token'];
|
||||
@@ -327,7 +334,19 @@ final class OssDriver implements StorageDriver
|
||||
}
|
||||
|
||||
/** 服务端读取(内网优先 + 自动回退外网) */
|
||||
public function get(string $key, array $extra = []): array
|
||||
/**
|
||||
* 取对象。
|
||||
*
|
||||
* @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。
|
||||
* **空 = 取原图**。非空时:
|
||||
* ① URL 追加 `?x-oss-process=...`
|
||||
* ② 签名把它计入 CanonicalizedResource(不然 403)
|
||||
*
|
||||
* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
|
||||
* 它会给你返回一张压缩过的图片文件。」—— 就是 `x-oss-process`。
|
||||
* **实时处理,原图不动**,所以不用改上传流程、不用二次存储。
|
||||
*/
|
||||
public function get(string $key, array $extra = [], string $process = ''): array
|
||||
{
|
||||
if ($err = $this->validateConfig()) {
|
||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||||
@@ -336,8 +355,14 @@ final class OssDriver implements StorageDriver
|
||||
if (empty($cred['ok'])) {
|
||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||||
}
|
||||
$key = $this->objectKey($key);
|
||||
$headers = $this->readHeaders($key, $cred, $extra);
|
||||
$key = $this->objectKey($key);
|
||||
|
||||
/* 子资源串:签名与 URL 必须**用同一个值**,否则签名对不上 */
|
||||
$sub = '';
|
||||
if ($process !== '') {
|
||||
$sub = '?x-oss-process=' . $process;
|
||||
}
|
||||
$headers = $this->readHeaders($key, $cred, $extra, 'GET', $sub);
|
||||
|
||||
$hosts = [];
|
||||
$internal = $this->internalEndpoint();
|
||||
@@ -349,8 +374,10 @@ final class OssDriver implements StorageDriver
|
||||
$res = null;
|
||||
foreach ($hosts as $i => $host) {
|
||||
$isInternal = ($internal !== '' && $host === $internal);
|
||||
/* ⚠️ process 里的逗号、下划线不需要 URL 编码(OSS 认原样),
|
||||
但 `?` 与 `=` 必须保留为分隔符 —— 所以直接拼,不 encode。 */
|
||||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
||||
. '/' . Http::encodeKey($key);
|
||||
. '/' . Http::encodeKey($key) . $sub;
|
||||
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
|
||||
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
|
||||
continue; // 内网连不上,换外网
|
||||
|
||||
+36
-6
@@ -153,14 +153,44 @@ final class Storage
|
||||
* 从本地搬到云端(或反过来)时,历史文件可能只在一边,
|
||||
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
|
||||
*/
|
||||
public static function read(int $familyId, string $key): array
|
||||
/**
|
||||
* 读一个对象。
|
||||
*
|
||||
* @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。
|
||||
*
|
||||
* 🔴 #56(用户 2026-11-05):「如果图片是放在本地服务器的…就不管它。
|
||||
* 如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」
|
||||
*
|
||||
* ## 两种驱动下的行为
|
||||
*
|
||||
* | 驱动 | `$process` 非空时 |
|
||||
* |---|---|
|
||||
* | `oss` | 透传给 `OssDriver::get()` ⇒ OSS 实时压缩 |
|
||||
* | `local` | **忽略** —— 本地没有图片处理服务,不该假装有 |
|
||||
*
|
||||
* ⚠️ 带 `$process` 时**跳过本地缓存**:那个缓存里是**原图**,
|
||||
* 拿原图当压缩版返回,等于压缩没生效而且用户以为生效了。
|
||||
* (缓存由 media.php 自己按尺寸分目录管理,见那里的说明。)
|
||||
*/
|
||||
public static function read(int $familyId, string $key, string $process = ''): array
|
||||
{
|
||||
$local = FB_UPLOADS . '/' . ltrim($key, '/');
|
||||
if ($key !== '' && is_file($local)) {
|
||||
$body = (string)@file_get_contents($local);
|
||||
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local'];
|
||||
/* 只有无 process 时才走"本地优先":原图有缓存就直接读,
|
||||
省掉整个 OSS round-trip。带 process 的必须去 OSS 要压缩版。 */
|
||||
if ($process === '') {
|
||||
$local = FB_UPLOADS . '/' . ltrim($key, '/');
|
||||
if ($key !== '' && is_file($local)) {
|
||||
$body = (string)@file_get_contents($local);
|
||||
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body,
|
||||
'error' => '', 'from' => 'local'];
|
||||
}
|
||||
}
|
||||
return self::disk($familyId)->get($key);
|
||||
|
||||
$disk = self::disk($familyId);
|
||||
/* 本地驱动不支持图片处理 ⇒ 忽略 process(用户明确要求"本地不管它") */
|
||||
if ($process === '' || $disk->id() !== 'oss') {
|
||||
return $disk->get($key);
|
||||
}
|
||||
return $disk->get($key, [], $process);
|
||||
}
|
||||
|
||||
public static function exists(int $familyId, string $key): bool
|
||||
|
||||
+112
@@ -557,12 +557,22 @@ final class Task
|
||||
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
|
||||
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。
|
||||
// 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。
|
||||
/* 🔴 #84(用户 2026-11-05):「鸿蒙端的分类那个地方没带进来。
|
||||
明明是有分类的,但是它却显示的是未分类。」
|
||||
|
||||
⇒ 补 `LEFT JOIN task_categories`。
|
||||
原来这条 SQL **没有 JOIN 分类表**,虽然 `t.*` 里有 `category_id`,
|
||||
但没有 `category_name` —— 端上 `category: s(d.category_name)`
|
||||
永远拿到空串,于是显示成「未分类」。
|
||||
(`doneList` / 详情页那几条 SQL 都有这个 JOIN,只有 find() 漏了。) */
|
||||
'SELECT t.*, p.title AS parent_title,
|
||||
c.name AS category_name,
|
||||
a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color,
|
||||
na.nickname AS assignee_nick,
|
||||
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
|
||||
nc.nickname AS creator_nick
|
||||
FROM tasks t
|
||||
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||
LEFT JOIN tasks p ON p.id = t.parent_id
|
||||
LEFT JOIN persons a ON a.id = t.assignee_id
|
||||
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
||||
@@ -721,6 +731,108 @@ final class Task
|
||||
*
|
||||
* @return int 因循环而新生成的任务 id(没有则 0)
|
||||
*/
|
||||
/**
|
||||
* 只改「截止时间」(#74)。
|
||||
*
|
||||
* 🔴 用户 2026-11-05:「点开之后,就可以修改任务的所有信息,包括…
|
||||
* 修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」
|
||||
*
|
||||
* ⚠️ **不要复用 `update()`**:那个方法是全字段覆盖(title/note/status/
|
||||
* assignee/repeat/parent/board/category 一起写),表单只交了 `due_at`
|
||||
* 的话,其余字段会被 `title('')`、`textOrNull(null)` 之类的默认值抹成空 ——
|
||||
* "只想改个日期,结果标题和备注没了",而且这种破坏不可恢复。
|
||||
* ⇒ 单独一条只写 `due_at` 的 UPDATE。
|
||||
*
|
||||
* 传空串 = 清掉截止时间(`dtOrNull` 会转成 NULL)。
|
||||
*
|
||||
* @return bool 是否真的更新了(任务不存在 / 不属于这个家庭时为 false)
|
||||
*/
|
||||
public static function setDue(int $familyId, int $id, string $dueAt): bool
|
||||
{
|
||||
$old = self::row($familyId, $id);
|
||||
if (!$old) {
|
||||
return false;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'UPDATE tasks SET due_at = ? WHERE id = ? AND family_id = ?'
|
||||
)->execute([self::dtOrNull($dueAt), $id, $familyId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只更新「截止时间 / 负责人 / 分类」三项(#76)。
|
||||
*
|
||||
* 🔴 用户 2026-11-05:「负责人和分类都没法修改,按道理讲都应该可以修改才对。」
|
||||
* 上一版面板里这三项都是只读的 `dl.kv` 行。
|
||||
*
|
||||
* ⚠️ **不要复用 `update()`**:那是全字段覆盖。面板只交这三项的话,
|
||||
* title / note / status / repeat / parent / board 会被默认值抹空 ——
|
||||
* 与 `setDue()` 记的是同一个坑。
|
||||
*
|
||||
* ⚠️ 三个参数都「显式传」才算改:`due_at` 传 `null` = 不动;
|
||||
* 传 `''` = 清掉。这样调用方可以只改其中一项。
|
||||
* (用 `array_key_exists` 判"传没传",而不是 `isset` ——
|
||||
* `isset(null)` 为 false,会把"显式传 null"误判成"没传"。)
|
||||
*
|
||||
* @param array $d 可含 due_at / assignee_id / category_id
|
||||
*/
|
||||
public static function setAttrs(int $familyId, int $id, array $d): bool
|
||||
{
|
||||
$old = self::row($familyId, $id);
|
||||
if (!$old) {
|
||||
return false;
|
||||
}
|
||||
$sets = [];
|
||||
$args = [];
|
||||
if (array_key_exists('due_at', $d)) {
|
||||
$sets[] = 'due_at = ?';
|
||||
$args[] = self::dtOrNull((string)($d['due_at'] ?? ''));
|
||||
}
|
||||
if (array_key_exists('assignee_id', $d)) {
|
||||
/* 0 / 空 = 不指定(存 NULL,与 update() 里 `?: null` 同一口径) */
|
||||
$sets[] = 'assignee_id = ?';
|
||||
$aid = (int)($d['assignee_id'] ?? 0);
|
||||
$args[] = $aid > 0 ? $aid : null;
|
||||
}
|
||||
if (array_key_exists('category_id', $d)) {
|
||||
$sets[] = 'category_id = ?';
|
||||
$args[] = self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0));
|
||||
}
|
||||
/* 🔴 #83(用户 2026-11-05):「分类和重复,也就是循环,都需要加上。」
|
||||
⇒ 面板里改成可编辑之后,得有一条能只写 repeat_rule 的路径。
|
||||
⚠️ 空串 = 不循环,存 NULL(与 `Task::update()` 的 `?: null` 同一口径)。
|
||||
⚠️ 白名单校验:只接受 `REPEAT` 里有的键,其余一律当"不循环"。
|
||||
不校验的话,前端传个乱值会进库,`repeatWord()` 那边就显示成空白。 */
|
||||
if (array_key_exists('repeat_rule', $d)) {
|
||||
$sets[] = 'repeat_rule = ?';
|
||||
$rr = trim((string)($d['repeat_rule'] ?? ''));
|
||||
$args[] = ($rr !== '' && isset(self::REPEAT[$rr])) ? $rr : null;
|
||||
}
|
||||
/* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。
|
||||
所以不需要再加一个编辑按钮了。」
|
||||
⇒ 删掉「编辑」按钮后,标题/备注也得能在这里改。
|
||||
⚠️ 走 `self::title()` / `self::textOrNull()` 而不是直接塞原值 ——
|
||||
与 `update()` 同一套清洗口径(去首尾空白、空备注存 NULL),
|
||||
否则同一条任务从两个入口改会得到不同形态的数据。 */
|
||||
if (array_key_exists('title', $d)) {
|
||||
$sets[] = 'title = ?';
|
||||
$args[] = self::title((string)($d['title'] ?? ''));
|
||||
}
|
||||
if (array_key_exists('note', $d)) {
|
||||
$sets[] = 'note = ?';
|
||||
$args[] = self::textOrNull((string)($d['note'] ?? ''));
|
||||
}
|
||||
if (!$sets) {
|
||||
return true; // 三个都没传:什么都不做,但也算"成功"(幂等)
|
||||
}
|
||||
$args[] = $id;
|
||||
$args[] = $familyId;
|
||||
Database::get()->prepare(
|
||||
'UPDATE tasks SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?'
|
||||
)->execute($args);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
|
||||
{
|
||||
$old = self::row($familyId, $id);
|
||||
|
||||
+215
-10
@@ -904,9 +904,152 @@ final class Trip
|
||||
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
|
||||
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
|
||||
*/
|
||||
/**
|
||||
* 行程动作常量(#48)。
|
||||
*
|
||||
* ⚠️ 只做三个动作,**不含"删除"**:删除走既有软删(`deleted_at`),
|
||||
* 与"取消"是两件完全不同的事 —— 用户明确要求区分:
|
||||
* · 取消 = 不去了,数据保留,列在「未成行」组
|
||||
* · 删除 = 彻底不要了,不再显示
|
||||
*/
|
||||
public const NODE_ACTIONS = ['start', 'done', 'cancel'];
|
||||
|
||||
/**
|
||||
* 执行一个行程动作(#48 的核心逻辑)。
|
||||
*
|
||||
* 用户原话(2026-11-05):
|
||||
* 「添加计划行程时,刚开始添加的时候,它的状态就是"计划中"。
|
||||
* 当我开始执行这个计划时…这时候就要记录住 8:30 这个时间,因为 8:30 会
|
||||
* 成为实际行程的开始时间。也就是说,原来计划的时间和现在真正开始的时间都要记录。
|
||||
* 一旦开始,这个行程的状态就变成"进行中"。等到走完了…我点"完成"。
|
||||
* 这时候这个内容或者计划行程就变成一个实际行程。
|
||||
* **一个实际行程没有再没有状态了,因为实际已经发生完了。**
|
||||
* 但如果这个计划我没有去做…点击一个按钮,然后它就变成了"取消"或者"删除"…
|
||||
* **取消和删除是不一样的**。」
|
||||
*
|
||||
* ## 三个动作
|
||||
*
|
||||
* | action | stage | status | 真实时间 |
|
||||
* |---|---|---|---|
|
||||
* | `start` | 不变(plan) | `ongoing` | 写 `actual_started_at` = 此刻 |
|
||||
* | `done` | **plan → actual** | `done` | 写 `actual_ended_at` = 此刻 |
|
||||
* | `cancel` | **plan → skipped** | `cancelled` | 不写 |
|
||||
*
|
||||
* ⚠️ `done` 会把 stage 改成 `actual` —— 这是"完成即转实际"的落点。
|
||||
* 转成 actual 之后,界面**不再显示任何状态**(用户:实际行程没状态)。
|
||||
* ⚠️ `cancel` 改成 `skipped` 而不是软删:数据要保留,
|
||||
* 最后旅程结束时要能看到"哪些计划没做"。
|
||||
*
|
||||
* @param string $action start / done / cancel
|
||||
* @return array{ok:bool,stage?:string,status?:string,error?:string}
|
||||
*/
|
||||
public static function nodeAction(int $familyId, int $eventId, int $nodeId, string $action): array
|
||||
{
|
||||
if (!in_array($action, self::NODE_ACTIONS, true)) {
|
||||
return ['ok' => false, 'error' => '不认识的动作。'];
|
||||
}
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$st->execute([$nodeId, $eventId]);
|
||||
$cur = $st->fetch();
|
||||
if (!$cur) {
|
||||
return ['ok' => false, 'error' => '找不到这段行程。'];
|
||||
}
|
||||
/* ⚠️ 已经转成实际行程的,不再接受状态类动作。
|
||||
用户:「如果这个行程已经转变成实际行程了…那这个行程就不再有其他状态了。」
|
||||
⇒ `done` 之后点 `start` / `cancel` 都无意义,直接拒绝。 */
|
||||
$curStage = (string)($cur['stage'] ?? 'plan');
|
||||
if ($curStage === 'actual' && $action !== 'done') {
|
||||
return ['ok' => false, 'error' => '这段已经是实际行程了,不再有状态。'];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stage = $curStage;
|
||||
$status = (string)($cur['status'] ?? 'planned');
|
||||
|
||||
if ($action === 'start') {
|
||||
$status = 'ongoing';
|
||||
/* 只在**第一次**开始时记录:重复点不该刷新真实开始时间 */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up = $db->prepare('UPDATE trip_nodes SET actual_started_at = ? WHERE id = ?');
|
||||
$up->execute([$now, $nodeId]);
|
||||
}
|
||||
} elseif ($action === 'done') {
|
||||
$stage = 'actual';
|
||||
$status = 'done';
|
||||
/* 若用户没点过「开始」直接点「完成」,用计划开始时间兜底记实际开始
|
||||
(否则实际行程缺开始时间,展示上少一半信息) */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up0 = $db->prepare(
|
||||
'UPDATE trip_nodes SET actual_started_at = COALESCE(starts_at, ?) WHERE id = ?'
|
||||
);
|
||||
$up0->execute([$now, $nodeId]);
|
||||
}
|
||||
$up2 = $db->prepare('UPDATE trip_nodes SET actual_ended_at = ? WHERE id = ?');
|
||||
$up2->execute([$now, $nodeId]);
|
||||
} else { // cancel
|
||||
$stage = 'skipped';
|
||||
$status = 'cancelled';
|
||||
}
|
||||
|
||||
/* 同步 category(老的双轨字段,由 stage 派生,两边不能打架) */
|
||||
$category = $stage === 'actual' ? 'reality' : 'plan';
|
||||
|
||||
$up3 = $db->prepare(
|
||||
'UPDATE trip_nodes SET stage = ?, status = ?, category = ?, updated_at = NOW()
|
||||
WHERE id = ?'
|
||||
);
|
||||
$up3->execute([$stage, $status, $category, $nodeId]);
|
||||
|
||||
return ['ok' => true, 'stage' => $stage, 'status' => $status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段行程该显示哪些动作(#48)。
|
||||
*
|
||||
* ⚠️ 前后端**共用同一份规则**,避免"网页端能点、端上不能点"这种不一致。
|
||||
*
|
||||
* 规则:
|
||||
* · `actual` → 空数组(实际行程没有状态类动作,只有编辑/删除)
|
||||
* · `skipped` → 空数组(已经取消,不再转)
|
||||
* · `plan` + planned → 开始
|
||||
* · `plan` + ongoing → 完成
|
||||
* · `plan` + 其他 → 开始
|
||||
*
|
||||
* @return string[] 动作 key 列表
|
||||
*/
|
||||
public static function nodeAvailableActions(string $stage, string $status): array
|
||||
{
|
||||
if ($stage === 'actual' || $stage === 'skipped') {
|
||||
/* ⚠️ 实际行程 / 未成行**没有状态动作** ——
|
||||
用户:「实际行程是没有状态的…实际已经发生完了,它再有状态就没有意义了。」 */
|
||||
return [];
|
||||
}
|
||||
if ($status === 'ongoing') {
|
||||
return ['done'];
|
||||
}
|
||||
return ['start'];
|
||||
}
|
||||
|
||||
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
|
||||
{
|
||||
$db = Database::get();
|
||||
/* 🔴 2026-11-05(#27 事故):**先取出当前行**,供下面"没传就沿用原值"用。
|
||||
⚠️ 不要用 `$cur ?? []` —— `saveNode()` 这层根本没有 `$cur`
|
||||
(那是 `ApiHandler::trips()` 的局部变量),
|
||||
写成 `?? []` 会永远拿到空数组,"保留原值"形同虚设。
|
||||
CUR_ROW_LOADED */
|
||||
$curRow = [];
|
||||
if ($id !== null && $id > 0) {
|
||||
$sc = $db->prepare('SELECT * FROM trip_nodes WHERE id = ?');
|
||||
$sc->execute([$id]);
|
||||
$got = $sc->fetch();
|
||||
if (is_array($got)) {
|
||||
$curRow = $got;
|
||||
}
|
||||
}
|
||||
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
|
||||
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
|
||||
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
|
||||
@@ -923,18 +1066,54 @@ final class Trip
|
||||
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
|
||||
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
|
||||
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
|
||||
$fromPlace = (string)($d['from_place'] ?? '');
|
||||
$toPlace = (string)($d['to_place'] ?? '');
|
||||
$fromAddr = trim((string)($d['from_addr'] ?? ''));
|
||||
$toAddr = trim((string)($d['to_addr'] ?? ''));
|
||||
$fromLng = self::coordOrNull($d['from_lng'] ?? null);
|
||||
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
|
||||
$toLng = self::coordOrNull($d['to_lng'] ?? null);
|
||||
$toLat = self::coordOrNull($d['to_lat'] ?? null);
|
||||
/* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
|
||||
|
||||
原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
|
||||
调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
|
||||
**库里原有的地点被静默抹掉**。
|
||||
|
||||
踩到的调用方:
|
||||
· 端上编辑浮层(旧版只传 `place`,不传 from_place/to_place)
|
||||
· 任何脚本/第三方调用
|
||||
· 我的验证脚本(就是这次事故的直接原因)
|
||||
|
||||
⇒ 改成 PATCH 语义:**没传就沿用原值,传了空串才是明确清空**。
|
||||
这样"用户主动删掉地点"仍然生效(他传空串),
|
||||
而"调用方不关心这个字段"不会造成破坏。
|
||||
|
||||
⚠️ `$cur` 是本方法开头查出来的当前行(编辑时有值,新建时为 null)。
|
||||
取不到时退回空串 —— 新建本来就该是空。 */
|
||||
$hasF = static fn(string $k): bool => array_key_exists($k, $d);
|
||||
$fromPlace = $hasF('from_place')
|
||||
? (string)$d['from_place'] : (string)($curRow['from_place'] ?? '');
|
||||
$toPlace = $hasF('to_place')
|
||||
? (string)$d['to_place'] : (string)($curRow['to_place'] ?? '');
|
||||
$fromAddr = $hasF('from_addr')
|
||||
? trim((string)$d['from_addr']) : (string)($curRow['from_addr'] ?? '');
|
||||
$toAddr = $hasF('to_addr')
|
||||
? trim((string)$d['to_addr']) : (string)($curRow['to_addr'] ?? '');
|
||||
$fromLng = $hasF('from_lng')
|
||||
? self::coordOrNull($d['from_lng']) : self::coordOrNull($curRow['from_lng'] ?? null);
|
||||
$fromLat = $hasF('from_lat')
|
||||
? self::coordOrNull($d['from_lat']) : self::coordOrNull($curRow['from_lat'] ?? null);
|
||||
$toLng = $hasF('to_lng')
|
||||
? self::coordOrNull($d['to_lng']) : self::coordOrNull($curRow['to_lng'] ?? null);
|
||||
$toLat = $hasF('to_lat')
|
||||
? self::coordOrNull($d['to_lat']) : self::coordOrNull($curRow['to_lat'] ?? null);
|
||||
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
|
||||
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
|
||||
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
/* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
|
||||
这没必要,直接城市就可以」)。
|
||||
|
||||
前端传来的可能是「山东省青岛市市南区」(高德的 district 整串),
|
||||
直接存会让旅程的城市聚合变成一长串行政区划。
|
||||
⇒ 过一道 `Geo::cityOnly()` 剥成纯市名「青岛市」。
|
||||
|
||||
⚠️ 不能指望前端改好就完事:老版本 App 还在用、
|
||||
第三方调用不经过我们的前端、老数据已经脏了。
|
||||
服务端归一化是**唯一的真防线**。 */
|
||||
$fromCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
$toCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
if ($fromCity === '') {
|
||||
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
||||
}
|
||||
@@ -1464,6 +1643,32 @@ final class Trip
|
||||
return self::NODE_TYPES[$key][1] ?? '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
|
||||
*
|
||||
* 🔴 2026-11-05(用户:「明明选的是坐火车,但网页版的图标显示的还是一辆汽车」)。
|
||||
*
|
||||
* 原来左侧只按 `node_type` 取 ⇒「赶路」永远显示 🚗,
|
||||
* 哪怕这段实际是坐火车(`travel_mode = 'train'` 应该是 🚄)。
|
||||
*
|
||||
* 现在:
|
||||
* ① 有出行方式 → 用它(火车 🚄 / 飞机 ✈️ / 轮船 🚢 / 自驾 🚗…)
|
||||
* ② 没有 → 退回节点类型(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
|
||||
*
|
||||
* ⚠️ 端上 `fbNodeLeadIcon()` 必须是**同一套优先级**,否则两边图标不一致。
|
||||
*/
|
||||
public static function leadIcon(string $nodeType, string $mode, int $familyId = 0): string
|
||||
{
|
||||
if ($mode !== '') {
|
||||
$mi = self::modeIcon($mode);
|
||||
/* modeIcon 对未知 key 回 '🧭'(不是空串),所以这里判空保底 */
|
||||
if ($mi !== '') {
|
||||
return $mi;
|
||||
}
|
||||
}
|
||||
return self::typeIcon($nodeType, $familyId);
|
||||
}
|
||||
|
||||
/** 出行方式的名字 / 图标 */
|
||||
public static function modeWord(string $key): string
|
||||
{
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* 视频转码(H.265/HEVC → H.264)。
|
||||
*
|
||||
* 🔴 为什么必须转(2026-11-05 用户问「网页端播放视频的问题解决了吗?」):
|
||||
*
|
||||
* 用户的视频是手机拍的 H.265/HEVC(`hvc1`)。浏览器支持现状:
|
||||
*
|
||||
* | 浏览器 | HEVC |
|
||||
* |---|---|
|
||||
* | Safari | ✅ 系统解码器 |
|
||||
* | Chrome Windows | ⚠️ 需装微软商店付费扩展 |
|
||||
* | Chrome macOS/Linux | ❌ |
|
||||
* | Firefox | ❌ |
|
||||
* | Edge | ⚠️ 同 Chrome |
|
||||
*
|
||||
* **不存在能解码 HEVC 的前端 JS 库** —— hls.js / flv.js / video.js
|
||||
* 都只是传输层封装,不提供编解码器。所以只能在服务端转。
|
||||
*
|
||||
* ## 转码参数为什么这样选
|
||||
*
|
||||
* -c:v libx264 -preset veryfast -crf 23
|
||||
* 兼容性最好的 H.264 编码器。`veryfast` 在服务器上速度可接受,
|
||||
* `crf 23` 是视觉无损区间(18 更大更清,28 更小更糊)。
|
||||
* -pix_fmt yuv420p
|
||||
* 必须显式指定:手机视频常是 yuv420p10le(10bit)或 4:2:2,
|
||||
* 这类像素格式 **Chrome 不认**,不转成 8bit 4:2:0 会照样黑屏。
|
||||
* -c:a aac -b:a 128k
|
||||
* 音频统一 AAC。HEVC 视频的音频常是 PCM/AC3,浏览器也不支持。
|
||||
* -movflags +faststart
|
||||
* 把 moov 移到文件头 ⇒ 可边下边播。原来有些视频 moov 在尾部,
|
||||
* 浏览器得下完整个文件才能开始播。
|
||||
* -vf scale='min(1920,iw)':-2
|
||||
* 宽度超过 1920 才缩,等比(-2 保证高度是偶数 —— yuv420 要求)。
|
||||
* 手机 4K 视频转完还是有 1080p,体积能小一半以上。
|
||||
*
|
||||
* ## 失败时的策略
|
||||
*
|
||||
* **转码失败不阻断上传** —— 原样保存。理由:
|
||||
* · 转码失败可能是 ffmpeg 挂了、磁盘满、编码异常,不该因此让用户传不了
|
||||
* · 原文件至少能下载后用本地播放器看(HEVC 系统播放器都支持)
|
||||
* · 失败原因写进 error 返回,界面能提示
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Video
|
||||
{
|
||||
/** ffmpeg 可执行文件(PATH 里有就直接写 ffmpeg) */
|
||||
private const FFMPEG = 'ffmpeg';
|
||||
|
||||
/** ffprobe */
|
||||
private const FFPROBE = 'ffprobe';
|
||||
|
||||
/** 转码超时(秒)。15MB 的视频 veryfast 大约 10~30 秒,给足余量。 */
|
||||
private const TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* 这个 MIME 是视频吗。
|
||||
*/
|
||||
public static function isVideo(string $mime): bool
|
||||
{
|
||||
return strpos(strtolower($mime), 'video/') === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg 是否可用(缓存一次,避免每次上传都 shell 一次)。
|
||||
*
|
||||
* ⚠️ 用静态变量缓存:一个请求里可能传多个附件,不必重复探测。
|
||||
*/
|
||||
public static function available(): bool
|
||||
{
|
||||
static $ok = null;
|
||||
if ($ok !== null) {
|
||||
return $ok;
|
||||
}
|
||||
$out = [];
|
||||
$code = 1;
|
||||
@exec(self::FFMPEG . ' -version 2>&1', $out, $code);
|
||||
$ok = $code === 0;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测视频的编码信息。
|
||||
*
|
||||
* @return array{codec:string,pix_fmt:string,width:int,height:int,duration:float}
|
||||
*/
|
||||
public static function probe(string $path): array
|
||||
{
|
||||
$empty = ['codec' => '', 'pix_fmt' => '', 'width' => 0, 'height' => 0, 'duration' => 0.0];
|
||||
$cmd = self::FFPROBE . ' -v error -select_streams v:0'
|
||||
. ' -show_entries stream=codec_name,pix_fmt,width,height'
|
||||
. ' -show_entries format=duration'
|
||||
. ' -of default=noprint_wrappers=1 '
|
||||
. escapeshellarg($path) . ' 2>&1';
|
||||
$out = [];
|
||||
$code = 1;
|
||||
@exec($cmd, $out, $code);
|
||||
if ($code !== 0) {
|
||||
return $empty;
|
||||
}
|
||||
$kv = [];
|
||||
foreach ($out as $line) {
|
||||
$p = strpos($line, '=');
|
||||
if ($p > 0) {
|
||||
$kv[substr($line, 0, $p)] = substr($line, $p + 1);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'codec' => strtolower((string)($kv['codec_name'] ?? '')),
|
||||
'pix_fmt' => strtolower((string)($kv['pix_fmt'] ?? '')),
|
||||
'width' => (int)($kv['width'] ?? 0),
|
||||
'height' => (int)($kv['height'] ?? 0),
|
||||
'duration' => (float)($kv['duration'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个视频需要转码吗。
|
||||
*
|
||||
* 判据(任一成立就要转):
|
||||
* ① 编码不是 h264 —— HEVC/VP9/AV1 在部分浏览器播不了
|
||||
* ② 像素格式不是 yuv420p/yuvj420p —— 10bit / 4:2:2 浏览器不认
|
||||
*
|
||||
* ⚠️ 已经符合的**不重复转**:转码要 CPU 也要时间,
|
||||
* 用户传 H.264 视频时不该白等。
|
||||
*/
|
||||
public static function needsTranscode(string $path): bool
|
||||
{
|
||||
$p = self::probe($path);
|
||||
if ($p['codec'] === '') {
|
||||
/* 探测失败(文件损坏/ffprobe 缺失)⇒ 不转,原样存,
|
||||
至少还能下载。硬转可能把好文件弄坏。 */
|
||||
return false;
|
||||
}
|
||||
if ($p['codec'] !== 'h264') {
|
||||
return true;
|
||||
}
|
||||
$okPix = ['yuv420p', 'yuvj420p', 'nv12'];
|
||||
return !in_array($p['pix_fmt'], $okPix, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转成 H.264。
|
||||
*
|
||||
* @return array{ok:bool,path?:string,error?:string,note?:string}
|
||||
* ok=true 时 `path` 是**新文件**的临时路径(调用方负责清理),
|
||||
* `note` 是给用户看的一句话(如「已从 H.265 转成 H.264」)。
|
||||
*/
|
||||
public static function toH264(string $src): array
|
||||
{
|
||||
if (!self::available()) {
|
||||
return ['ok' => false, 'error' => '服务器没有 ffmpeg,视频无法转码'];
|
||||
}
|
||||
$before = self::probe($src);
|
||||
$dst = $src . '.h264.mp4';
|
||||
|
||||
/* 缩放:宽超过 1920 才缩;-2 保证高度偶数(yuv420 要求) */
|
||||
$scale = "scale='min(1920,iw)':-2";
|
||||
|
||||
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
|
||||
. ' -i ' . escapeshellarg($src)
|
||||
. ' -c:v libx264 -preset veryfast -crf 23'
|
||||
. ' -pix_fmt yuv420p'
|
||||
. ' -vf ' . escapeshellarg($scale)
|
||||
. ' -c:a aac -b:a 128k'
|
||||
. ' -movflags +faststart'
|
||||
. ' ' . escapeshellarg($dst)
|
||||
. ' 2>&1';
|
||||
|
||||
$out = [];
|
||||
$code = 1;
|
||||
@exec('timeout ' . self::TIMEOUT . ' ' . $cmd, $out, $code);
|
||||
|
||||
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
|
||||
@unlink($dst);
|
||||
$msg = trim(implode(' ', array_slice($out, 0, 3)));
|
||||
return ['ok' => false, 'error' => '转码失败' . ($msg !== '' ? ':' . $msg : '')];
|
||||
}
|
||||
|
||||
$after = self::probe($dst);
|
||||
/* 转完自检:必须真的是 h264 + yuv420p,否则别拿去用 */
|
||||
if ($after['codec'] !== 'h264' || !in_array($after['pix_fmt'], ['yuv420p', 'yuvj420p'], true)) {
|
||||
@unlink($dst);
|
||||
return ['ok' => false, 'error' => '转码结果异常(' . $after['codec'] . '/' . $after['pix_fmt'] . ')'];
|
||||
}
|
||||
|
||||
$fromName = $before['codec'] !== '' ? strtoupper($before['codec']) : '原格式';
|
||||
return [
|
||||
'ok' => true,
|
||||
'path' => $dst,
|
||||
'note' => '已从 ' . $fromName . ' 转成 H.264(' . $before['width'] . 'x' . $before['height']
|
||||
. ' → ' . $after['width'] . 'x' . $after['height'] . ')',
|
||||
];
|
||||
}
|
||||
}
|
||||
+99
-2
@@ -88,7 +88,31 @@ function avatar_url(?string $key): string
|
||||
* 附件代理地址。
|
||||
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
|
||||
*/
|
||||
function media_url(?string $key, array $extra = []): string
|
||||
/**
|
||||
* 附件访问地址(走 media.php 代理)。
|
||||
*
|
||||
* @param array $extra 额外参数:`w`(压缩档位 1600)、`dl`(下载)等
|
||||
* @param int $ver 内容版本(通常是附件 size)。
|
||||
* **变了就让 URL 变**,从而绕过一切缓存。
|
||||
*
|
||||
* 🔴 #57(用户:「他说是H.265,还是不让播呢?」):
|
||||
* 转码后服务端确实是 h264 了(15 个视频全部实测过),但浏览器还在放旧的。
|
||||
*
|
||||
* 原因是**媒体缓存**:`Cache-Control: max-age=604800`(7 天),
|
||||
* 而浏览器对 `<video>` 有自己的一套:
|
||||
* · 不完全遵守 ETag
|
||||
* · `Range` 分段缓存会把每段当独立资源
|
||||
* · `src` 不变时直接复用已有媒体对象
|
||||
*
|
||||
* ⇒ 最可靠的做法是**让 URL 变**。URL 一变任何缓存层都不会命中。
|
||||
*
|
||||
* ⚠️ 版本号用 `size` 而不是 `mtime`/`md5`:
|
||||
* · `mtime`:对象在 OSS 上,本地拿不到可靠时间
|
||||
* · `md5`:要读整个文件,几百 MB 的视频不可接受
|
||||
* · `size` 够用(转码前后体积必然不同:1.22MB → 2.19MB),
|
||||
* 而且它本来就在数据库里,零开销。
|
||||
*/
|
||||
function media_url(?string $key, array $extra = [], int $ver = 0): string
|
||||
{
|
||||
$key = trim((string)$key);
|
||||
if ($key === '') {
|
||||
@@ -98,7 +122,31 @@ function media_url(?string $key, array $extra = []): string
|
||||
if (preg_match('#^https?://#i', $key)) {
|
||||
return $key;
|
||||
}
|
||||
return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra));
|
||||
$q = array_merge(['k' => ltrim($key, '/')], $extra);
|
||||
|
||||
/* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
|
||||
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」):
|
||||
|
||||
**`w`(压缩档位)只允许给图片。** 给视频传
|
||||
`x-oss-process=image/resize,l_1600` 会让 OSS 图片处理服务
|
||||
对非图片返回错误内容 ⇒ `<video>` 播不了。
|
||||
|
||||
这里用扩展名判断(只有 key、没有 MIME):
|
||||
· 图片扩展名 → 保留 w
|
||||
· 其它(mp4/pdf…) → **去掉 w**
|
||||
⚠️ `v` 版本号**所有类型都保留**:视频转码后也要靠它破缓存。
|
||||
它只是查询串,不参与 OSS 签名。 */
|
||||
if (isset($q['w'])) {
|
||||
$isImg = (bool)preg_match('#\.(jpe?g|png|gif|webp|bmp|avif|heic)$#i', $key);
|
||||
if (!$isImg) {
|
||||
unset($q['w']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($ver > 0) {
|
||||
$q['v'] = $ver;
|
||||
}
|
||||
return 'media.php?' . http_build_query($q);
|
||||
}
|
||||
|
||||
// ---------------- 人:怎么称呼、什么颜色 ----------------
|
||||
@@ -378,6 +426,40 @@ function days_until_birthday(?string $birthday): ?int
|
||||
* @param array $extra 额外要显示的行 [[标签, 值], …]
|
||||
* @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义)
|
||||
*/
|
||||
/**
|
||||
* 把 hex 主题色往白色方向混,得到一档浅色。
|
||||
*
|
||||
* 🔴 #85(用户 2026-11-05):「参考一下那个反馈系统里面的下拉框到底是怎么做的…
|
||||
* 它也有主题颜色,我们这个也有主题颜色,就按照一样的做法去做。」
|
||||
*
|
||||
* FeedLite 那边用三档浅色区分下拉项的三态:
|
||||
* tint-1(混 91% 白)→ hover
|
||||
* tint-2(混 80% 白)→ 选中项(比 hover 深一档,一眼分开)
|
||||
* tint-3(混 72% 白)→ 选中项 hover
|
||||
*
|
||||
* ⚠️ 在 PHP 里算好再输出,**不用 CSS 的 `color-mix()`** ——
|
||||
* FeedLite 实测过:`color-mix(in srgb, var(--brand) 9%, #fff)`
|
||||
* 在 Chrome 里解析成透明(规则匹配上了但不生效)。
|
||||
* ⚠️ `$ratio` 是"往白里混多少",不是"保留多少":
|
||||
* 0.91 = 91% 白 + 9% 原色(很浅),0.0 = 原色本身。
|
||||
*/
|
||||
function fb_color_tint(string $hex, float $ratio): string
|
||||
{
|
||||
$hex = trim($hex);
|
||||
if (preg_match('/^#?([0-9a-fA-F]{6})$/', $hex, $m) !== 1) {
|
||||
return ''; // 不是 6 位 hex(比如 rgba()/色名)就放弃,调用方退回默认
|
||||
}
|
||||
$h = $m[1];
|
||||
$r = (int)hexdec(substr($h, 0, 2));
|
||||
$g = (int)hexdec(substr($h, 2, 2));
|
||||
$b = (int)hexdec(substr($h, 4, 2));
|
||||
$ratio = max(0.0, min(1.0, $ratio));
|
||||
return sprintf('#%02x%02x%02x',
|
||||
(int)round($r + (255 - $r) * $ratio),
|
||||
(int)round($g + (255 - $g) * $ratio),
|
||||
(int)round($b + (255 - $b) * $ratio));
|
||||
}
|
||||
|
||||
function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string
|
||||
{
|
||||
$date = (string)($ev['date'] ?? '');
|
||||
@@ -398,6 +480,16 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
|
||||
if ($when !== '') { $rows[] = ['时间', $when]; }
|
||||
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
||||
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
|
||||
/* 🔴 #67(用户 2026-11-05):「如果行程里面有地址,也就是有出发地和目的地,
|
||||
应该像行程里显示的那样,把它显示在下面,这样一点就可以导航。」
|
||||
⇒ 行程派生条目(kind=trip)额外给两行:出发地 / 目的地。
|
||||
@ 用两行而不是一行「A → B」:详情卡是 `dl.kv` 两列表格,
|
||||
一行一个地名读起来更清楚,也便于将来给单个地名挂导航链接。
|
||||
@ 普通日程没有这两个字段(),下面的 if 自然不产出。 */
|
||||
$evFrom = (string)($ev['from_place'] ?? '');
|
||||
$evTo = (string)($ev['to_place'] ?? '');
|
||||
if ($evFrom !== '') { $rows[] = ['出发地', $evFrom]; }
|
||||
if ($evTo !== '') { $rows[] = ['目的地', $evTo]; }
|
||||
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
|
||||
当然,如果没有地点,这一行就不要显示了。」
|
||||
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
|
||||
@@ -541,6 +633,11 @@ function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
|
||||
'id' => $taskId,
|
||||
'status' => (string)($t['status'] ?? ''),
|
||||
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
||||
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
|
||||
前端 `syncRow()` 原来只同步状态徽标,不同步标题。
|
||||
标题可以从面板输入框读,但那条路依赖 DOM 还在 ——
|
||||
服务端直接回一份才是稳的(同一个函数给所有面板动作复用)。 */
|
||||
'title' => (string)($t['title'] ?? ''),
|
||||
'html' => $html,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
+469
-4
@@ -2412,7 +2412,19 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
max-width: 92vw; max-height: 100%; width: auto; height: auto;
|
||||
object-fit: contain; border-radius: 8px;
|
||||
}
|
||||
.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; }
|
||||
/* 🔴 2026-11-05 修:视频打开后是黑的(用户:「点开之后是黑的,没办法播放」)。
|
||||
原因:`max-height: 100%` 依赖父级的**确定高度**,而 `.pv-stage` 是
|
||||
flex + `align-items:center`,百分比高度解析不出具体像素
|
||||
⇒ `<video>` 按 0 高度渲染 ⇒ 只剩 background:#000 一块黑。
|
||||
改成 `vh`(相对视口,永远是确定值)+ `object-fit: contain`(竖屏不变形)。 */
|
||||
.pv-video {
|
||||
max-width: 92vw;
|
||||
max-height: 82vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.pv-pdf {
|
||||
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
|
||||
}
|
||||
@@ -3949,8 +3961,56 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
font-size: 11.5px; padding: 1px 8px; border-radius: 5px;
|
||||
background: var(--line-2); color: var(--muted);
|
||||
}
|
||||
.node-route-line { font-size: 12.5px; color: var(--muted); }
|
||||
.node-route-line .arrow { color: var(--faint); margin: 0 3px; }
|
||||
/* 🔴 2026-11-05 重做(用户:「这个位都没对齐呀…直接用个标签,然后右边一个箭头,
|
||||
到北京南站就得了,不用写从哪到哪」)。
|
||||
|
||||
原来只是一个普通 div,里面的 <a> 与 <span class="arrow"> 各按自己的基线排
|
||||
⇒ 字号不同就"没对齐"。
|
||||
|
||||
现在:容器 flex + align-items:center,两个地名各自成胶囊标签,
|
||||
箭头只作窄分隔。视觉上就是「[青岛北站] → [北京南站]」,
|
||||
不再需要"从/到"这种字。 */
|
||||
.node-route-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 地名标签:浅底圆角,长名字省略 */
|
||||
.node-route-line > a,
|
||||
.node-route-line > span.route-stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 46%;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--fill, #F1EAE1);
|
||||
color: var(--ink, #2C231D);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-route-line > a:hover {
|
||||
background: var(--brand-soft, #F5E3DC);
|
||||
}
|
||||
|
||||
/* 箭头:窄、淡,只做分隔 */
|
||||
.node-route-line .arrow {
|
||||
color: var(--faint);
|
||||
margin: 0 1px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
}
|
||||
.node-spent { font-size: 12.5px; color: var(--brand-ink); }
|
||||
.node-member { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.node-role {
|
||||
@@ -6854,12 +6914,179 @@ h6.md-h { font-size: 17px; color: var(--muted); }
|
||||
.tk-sec + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; }
|
||||
.tk-sec-h {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; font-weight: 600; color: var(--ink);
|
||||
/* #82:小节标题改 **模块主题色 + 加粗 + 字距** ——
|
||||
与鸿蒙端 FbSectionTitle 一致(用户:「颜色也是用主题颜色」)。 */
|
||||
font-size: 13px; font-weight: 700; letter-spacing: .6px;
|
||||
color: var(--tk-accent, var(--brand));
|
||||
}
|
||||
.tk-sec-h .group-n { margin-left: 0; }
|
||||
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
|
||||
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
|
||||
.tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
/* 🔴 #74:面板里的「截止时间」AntD 控件。
|
||||
`.dt-input` 是 `display:flex; flex-wrap:wrap`,放进 `.tk-sec-inline`
|
||||
(也是 flex-wrap)里会被当成一个弹性项 —— 不给宽度约束时它会按内容撑,
|
||||
在窄屏上可能把「截止」标签挤到上一行、或者控件自己溢出。
|
||||
`flex: 1 1 auto` + `min-width: 0` 让它占满标签右侧的剩余宽度,
|
||||
`max-width` 兜住极宽屏(AntD 的 picker 本身有固定宽度)。 */
|
||||
.tk-sec-inline > .dt-input { flex: 1 1 auto; min-width: 0; max-width: 320px; }
|
||||
/* 「还剩 3 天」那类小字:贴在控件右边,不与控件抢宽度 */
|
||||
.tk-due-word { flex: 0 0 auto; font-size: 12.5px; color: var(--faint); white-space: nowrap; }
|
||||
.tk-due-word.kb-late { color: var(--red); }
|
||||
|
||||
/* 🔴 #76:「更多信息」小节 —— 截止 / 负责人 / 分类 三行 + 保存。
|
||||
用户 2026-11-05:「没有保存按钮…负责人和分类都没法修改。」
|
||||
每行结构 = 固定宽标签 + 占满剩余宽度的控件,与 .mv-row 同一套观感
|
||||
(但标签用 tk-attr-lb,因为面板里字号比整页表单小一号)。 */
|
||||
.tk-attrs { display: flex; flex-direction: column; gap: 14px; }
|
||||
/* 🔴🔴 #82(用户 2026-11-05):「网页版编辑在首页编辑任务的时候,那个输入框
|
||||
怎么那么短呢?所有输入框都应该跟弹出框一样宽。」
|
||||
「布局顺序也和鸿蒙版一致」「下面分成两行:第一行是任务标题,第二行是状态」
|
||||
|
||||
⇒ `.tk-attr-row` 从**横向**(标签左 / 控件右)改成**纵向两行**:
|
||||
① 标签独占一行(主题色粗体)
|
||||
② 控件独占一行,**占满整行宽度**(不再有 300px 上限)
|
||||
|
||||
为什么去掉 300px 上限:
|
||||
那个上限是 #80 为"对齐"引入的,但它的代价是**输入框比面板窄一大截** ——
|
||||
面板 680 宽,控件只有 300,右边空着 300 多像素,看起来像没做完。
|
||||
现在改成上下两行之后,每行只有一个控件,天然左缘对齐,
|
||||
**不再需要靠限宽来对齐** ⇒ 直接 width:100%。 */
|
||||
.tk-attr-row {
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
|
||||
/* 🔴 #80:这里原来有 `flex-wrap: wrap` —— 窄屏下控件(300px)加标签(46px)
|
||||
加间距(10px)放不进容器,控件就**掉到下一行**,左缘从 76 变成 0,
|
||||
与标签右缘完全错开,整列看着像塌了。
|
||||
⇒ 不换行:让控件按 `flex:1 1 auto` + `min-width:0` 收缩到可用宽度。
|
||||
宽度不足时宁可控件变窄,也不要换行破坏对齐。
|
||||
⚠️ 代价:极窄屏(< 200px)下控件会很窄 —— 但那时任何布局都放不下,
|
||||
保持对齐比保持宽度更重要。 */
|
||||
}
|
||||
.tk-attr-lb {
|
||||
/* #82:标签不再固定 46px(那是横排时的"对齐基准"),
|
||||
现在独占一行,按内容宽即可;左对齐(横排时是右对齐贴控件)。
|
||||
颜色从 faint 灰改成 **模块主题色 + 加粗** —— 与鸿蒙端的 fieldLb、
|
||||
以及本页的小节标题同一口径。 */
|
||||
font-size: 12.5px; font-weight: 700; letter-spacing: .6px;
|
||||
color: var(--tk-accent, var(--brand)); text-align: left;
|
||||
}
|
||||
/* 控件区占满标签右侧(min-width:0 让它能被压缩,不会把标签挤出去) */
|
||||
.tk-attr-row > *:not(.tk-attr-lb) { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
/* 🔴 #80(用户 2026-11-05):「标题和备注的输入框没有和其他输入框对齐。」
|
||||
|
||||
根因:各行的**宽度上限不一致** ——
|
||||
· 截止(AntD picker) max-width: 300px
|
||||
· 分类(select) max-width: 300px
|
||||
· 负责人/协作人 max-width: 340px
|
||||
· **标题(input)/ 备注(textarea)完全没设上限** ⇒ 撑满整行
|
||||
于是标题那行比截止那行宽出一截,左右边缘都对不齐。
|
||||
|
||||
⇒ 统一成一个宽度变量。以后要调只改这一处,不会再有"某一行多一点"。
|
||||
|
||||
⚠️ `width: 100%` + `max-width` 而不是只给 `max-width`:
|
||||
只给 max-width 时,块级 input 默认 width:auto 会按内容收缩,
|
||||
反而比别的行窄。两个一起给才能"最多这么宽、但尽量占满"。
|
||||
⚠️ 标签宽度(46px)+ gap(10px)= 56px,这与 `.tk-attr-actions` 的
|
||||
`padding-left: 56px` 是同一个数 —— 改标签宽度时必须同步改那两处。 */
|
||||
:root { --tk-attr-w: 300px; }
|
||||
/* #82:所有控件一律占满整行 —— 不再有 `--tk-attr-w` 这个 300px 上限。
|
||||
上下两行之后每行只有一个控件,宽度不一致反而不协调。 */
|
||||
.tk-attr-row > .dt-input,
|
||||
.tk-attr-row > select.select,
|
||||
.tk-attr-row > .pp-root,
|
||||
.tk-attr-row > input.input,
|
||||
.tk-attr-row > textarea.input,
|
||||
.tk-attr-row > .tk-attr-val { width: 100%; max-width: none; }
|
||||
/* textarea 按行数撑高,别被 `.input` 的固定高度带偏 */
|
||||
.tk-attr-row > textarea.input { resize: vertical; min-height: 46px; }
|
||||
/* 保存按钮那一行:与上面的控件左缘对齐(= 46px 标签宽 + 10px gap) */
|
||||
.tk-attr-actions {
|
||||
/* #82:取消 padding-left(56px) —— 那是给"横排时对齐控件左缘"用的。
|
||||
现在控件占满整行,按钮左缘就是容器左缘,再加 56 会缩进去一截。 */
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
/* 🔴 #83(用户 2026-11-05):「保存按钮感觉太小了。要不也像鸿蒙版一样长长的一条?」
|
||||
|
||||
原来 `.btn-sm` 是内联宽度(按文字撑),在 648px 宽的面板里显得又小又偏。
|
||||
⇒ 面板里的保存按钮改成**整行长条**:与上面的输入框同宽、同高(42),
|
||||
视觉上就是"这一列的最后一步"。
|
||||
⚠️ 用 `width:100%` 而不是 `flex:1`:`.tk-attr-actions` 是 flex 容器,
|
||||
`flex:1` 在有其它兄弟时会按比例分 —— 这里只有一个按钮,
|
||||
`width:100%` 更直接、不受后续增删影响。 */
|
||||
.tk-attr-actions > .btn {
|
||||
width: 100%; height: 42px; justify-content: center;
|
||||
font-size: 14px; font-weight: 500; border-radius: var(--radius-s);
|
||||
}
|
||||
.tk-attr-tip { font-size: 12px; color: var(--green, #2E7D4F); }
|
||||
/* #82:只读态的值也做成"框"的样子(浅底圆角),
|
||||
与可编辑态同宽同高 —— 否则只读用户看到的是一行裸文字,和编辑态差太多。 */
|
||||
.tk-attr-val {
|
||||
display: block; min-height: 40px; box-sizing: border-box;
|
||||
padding: 10px 12px; font-size: 14px; color: var(--ink);
|
||||
background: var(--input-bg, #F7F4EF);
|
||||
border-radius: var(--radius-s); word-break: break-word;
|
||||
}
|
||||
/* #82:面板里的"点一下就能改"的横排段(状态/看板)改成上下两行:
|
||||
标签在上、chips 在下,与字段行同形态。 */
|
||||
.tk-sec-inline { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.tk-sec-inline > .tk-sec-lb {
|
||||
font-size: 12.5px; font-weight: 700; letter-spacing: .6px;
|
||||
color: var(--tk-accent, var(--brand));
|
||||
}
|
||||
|
||||
/* #78:面板里的选人组件(负责人单选 / 协作人多选)。
|
||||
宽度已在上面的 `--tk-attr-w` 统一,这里只管 chips 的间距。
|
||||
(原来这里单独写 max-width: 340px,比其他行宽 40px —— 正是 #80 对不齐的原因之一。) */
|
||||
.tk-attr-row > .pp-root .pp-chips { margin-bottom: 6px; }
|
||||
/* 标签在多行控件(备注、协作人)上方对齐,而不是垂直居中 */
|
||||
.tk-attr-row-top { align-items: flex-start; }
|
||||
.tk-attr-row-top > .tk-attr-lb { padding-top: 8px; }
|
||||
/* 只读态的协作人 chips */
|
||||
.tk-share-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tk-share-chip {
|
||||
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
|
||||
background: var(--surface-2, #F2F0EC); color: var(--ink);
|
||||
}
|
||||
|
||||
/* #78:评论里的「添加附件」。
|
||||
用原生 file input(视觉隐藏),点 label 触发 —— 与站内其它上传入口同一手法。 */
|
||||
/* 🔴 #81(用户 2026-11-05):「评论里面拖文件上传附件,那个地方太小了。」
|
||||
|
||||
原来是一个 `padding:4px 10px` 的小胶囊(高约 26px)+ 一行小字,
|
||||
拖动落点太小:触屏上不好瞄,桌面上也容易拖偏。
|
||||
⇒ 改用与「任务附件」同一套 `.upbox`(96px 高的虚线大框)。
|
||||
这里只做评论特有的微调,不去覆盖 `.upbox` 本体 ——
|
||||
那一套是站点级组件,改它会影响所有上传入口。 */
|
||||
.cmt-attach-row {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 评论里的上传框比「任务附件」矮一点:评论框本身已经在下方,
|
||||
96px 会把整块讨论区推得太长;72px 在触屏上仍然好点。 */
|
||||
.cmt-upbox { min-height: 72px; padding: 10px 14px; gap: 3px; }
|
||||
.cmt-upbox .upbox-ico { font-size: 18px; }
|
||||
.cmt-upbox .upbox-msg { font-size: 13px; }
|
||||
.cmt-upbox-sub {
|
||||
font-size: 11.5px; color: var(--faint); margin-top: 1px;
|
||||
}
|
||||
/* 拖拽经过时整框高亮(`.is-over` 由 task_panel.js 加在 `.cmt-attach-row` 上,
|
||||
而 `.upbox.is-over` 是站点级规则 —— 这里补一条子选择器把它传导下去) */
|
||||
.cmt-attach-row.is-over .cmt-upbox {
|
||||
border-color: var(--brand); border-style: solid;
|
||||
background: #FDF3EC; color: var(--brand);
|
||||
}
|
||||
/* 已选文件列表:一行一个,前面带个圆点;超长名截断 */
|
||||
.cmt-file-list {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
padding: 6px 10px; border-radius: var(--radius-s);
|
||||
background: var(--surface-2, #F2F0EC);
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
.cmt-file-list span {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cmt-file-list span::before { content: '📎 '; }
|
||||
.tk-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
|
||||
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
|
||||
@@ -7004,3 +7231,241 @@ label.is-req,
|
||||
.dark .mv-row.is-req > .mv-lb {
|
||||
color: #F08A78 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
#47(2026-11-05)行程状态动作按钮
|
||||
------------------------------------------------------------
|
||||
用户:「做成两个按钮,颜色也尽可能保持一致…
|
||||
选中其中一个按钮之后…还加了一个那种椭圆的那种框那种背景,灰色的。」
|
||||
|
||||
实现:胶囊(border-radius:999px)+ 浅底;
|
||||
`.is-on` 是"当前状态",用实心主题色表示"正在被选中",
|
||||
其余动作是浅底 —— 一眼能看出现在是什么、能改成什么。
|
||||
============================================================ */
|
||||
.node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
.node-act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
background: var(--fill, #F1EAE1);
|
||||
color: var(--muted, #8A7A6D);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
|
||||
.node-act:hover { background: var(--brand-soft, #F5E3DC); }
|
||||
|
||||
/* 当前状态:实心 = 正在被选中 */
|
||||
.node-act.is-on {
|
||||
background: var(--muted, #8A7A6D);
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.node-act-go { color: #2F6FA8; }
|
||||
.node-act-go:hover { background: #EAF2F9; }
|
||||
|
||||
.node-act-ok { color: #3F7D5A; }
|
||||
.node-act-ok:hover { background: #EAF4EE; }
|
||||
|
||||
.node-act-no { color: #C0533F; }
|
||||
.node-act-no:hover { background: #FBEDEA; }
|
||||
|
||||
.node-act-gray { color: var(--muted, #8A7A6D); }
|
||||
|
||||
|
||||
/* #48(2026-11-05)补充:取消按钮(琥珀色,与"删除"的砖红区分开)。
|
||||
用户强调「取消和删除是不一样的」⇒ 视觉上也不能同色。 */
|
||||
.node-act-warn { color: #B8862B; }
|
||||
.node-act-warn:hover { background: #FDF6E7; }
|
||||
|
||||
/* 时间行:计划与实际并列时的区分 */
|
||||
.node-time .t-plan { color: var(--muted, #8A7A6D); }
|
||||
.node-time .t-actual {
|
||||
color: var(--brand, #A8574F);
|
||||
margin-left: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
#53(2026-11-05)视频解不了时的提示卡片
|
||||
------------------------------------------------------------
|
||||
用户传的视频是 H.265/HEVC,Chrome/Firefox 解不了 ⇒ 黑屏。
|
||||
不给提示的话用户会以为"文件坏了/网站坏了"。
|
||||
这里把原因和出路直接写在画面里。
|
||||
============================================================ */
|
||||
.pv-fallback {
|
||||
max-width: 520px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .08);
|
||||
border: 1px solid rgba(255, 255, 255, .18);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
.pv-fallback-h {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pv-fallback-p {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
opacity: .82;
|
||||
}
|
||||
.pv-fallback-dl {
|
||||
display: inline-block;
|
||||
padding: 9px 22px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pv-fallback-dl:hover { background: #eee; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
#85 自绘下拉框(替换原生 select 的展开列表)
|
||||
══════════════════════════════════════════════════════════════════════
|
||||
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
|
||||
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做,它也有主题颜色,
|
||||
我们这个也有主题颜色,就按照一样的做法去做。」
|
||||
|
||||
**为什么必须自绘**:原生 `<select>` 收起时能改样式,
|
||||
但**展开的选项列表由浏览器/系统绘制,CSS 完全改不动**(只能改 option 底色),
|
||||
各平台样式还不统一。所以想"好看"只有一条路:自己画。
|
||||
|
||||
**但绝不重写 HTML**:原生 select 保留在 DOM 里参与表单提交,
|
||||
JS 在它旁边生成一个按钮 + 浮层。这样:
|
||||
· 全站所有 select 自动获得新样式,不用逐个改模板;
|
||||
· `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change);
|
||||
· 无 JS 时原生 select 仍可用(只是朴素),不会白屏。
|
||||
|
||||
⚠️ 浮层用 `position: fixed` 挂在 body 上 —— 面板/浮层里有很多
|
||||
`overflow: hidden`,放在 select 旁边会被裁掉(FeedLite 踩过)。
|
||||
⚠️ 三档浅色由 layout.php 里 PHP 算好(--brand-tint-1/2/3),
|
||||
不用 `color-mix()`:那个配 var() 在 Chrome 里会解析成透明。
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 原生 select:视觉隐藏但仍参与提交与事件(**不能 display:none**,那样不参与提交) */
|
||||
.sel-native {
|
||||
position: absolute !important;
|
||||
width: 1px !important; height: 1px !important;
|
||||
margin: 0 !important; padding: 0 !important;
|
||||
border: 0 !important; opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
}
|
||||
|
||||
/* 外层:inline-flex,跟输入框一样参与行内布局 */
|
||||
.sel { position: relative; display: inline-flex; vertical-align: middle; max-width: 100%; min-width: 0; }
|
||||
|
||||
/* 触发按钮:外观与普通输入框完全一致(同高同圆角同边框) */
|
||||
.sel-btn {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
height: 40px; padding: 0 12px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
background: #fff; color: var(--ink);
|
||||
font-family: inherit; font-size: 14px; line-height: 1;
|
||||
cursor: pointer; text-align: left;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04);
|
||||
transition: border-color .12s, box-shadow .12s, background .12s;
|
||||
max-width: 100%; min-width: 0;
|
||||
}
|
||||
.sel-btn:hover { border-color: var(--brand-2); }
|
||||
.sel-btn:focus-visible { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-tint-1, rgba(168,74,40,.10)); }
|
||||
.sel-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 未选值(「未分类」「不循环」这类占位)→ 次要色,弱化存在感 */
|
||||
.sel-btn.is-placeholder .sel-txt { color: var(--muted); }
|
||||
|
||||
/* 自绘箭头:默认灰;**打开时转 180° 并变主题色** */
|
||||
.sel-caret {
|
||||
width: 11px; height: 7px; flex: none;
|
||||
background: currentColor; opacity: .55;
|
||||
-webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
transition: transform .16s, background .12s, opacity .12s;
|
||||
}
|
||||
.sel-btn.is-open .sel-caret { transform: rotate(180deg); background: var(--brand); opacity: 1; }
|
||||
/* 打开态整体染主题色:浅主题底 + 主题边框 */
|
||||
.sel-btn.is-open {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-tint-1, #FAF2EE);
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-tint-1, rgba(168,74,40,.10));
|
||||
}
|
||||
.sel-btn.is-open .sel-txt { color: var(--brand); font-weight: 600; }
|
||||
|
||||
/* 浮层:fixed,不被任何 overflow 容器裁切 */
|
||||
.sel-panel {
|
||||
position: fixed; z-index: 9998;
|
||||
max-height: min(320px, calc(100vh - 32px));
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
box-shadow: 0 10px 30px rgba(16,24,40,.16), 0 2px 6px rgba(16,24,40,.06);
|
||||
animation: selIn .12s ease-out;
|
||||
}
|
||||
@keyframes selIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||
.sel-panel.is-up { animation-name: selInUp; }
|
||||
@keyframes selInUp { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* 列表项 */
|
||||
.sel-item {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
padding: 0 10px; height: 34px;
|
||||
border: 0; border-radius: 7px;
|
||||
background: transparent; color: var(--ink);
|
||||
font-family: inherit; font-size: 14px; line-height: 1;
|
||||
cursor: pointer; text-align: left; white-space: nowrap;
|
||||
transition: background .1s, color .1s;
|
||||
-webkit-appearance: none; appearance: none;
|
||||
}
|
||||
.sel-item-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* hover / 键盘高亮:主题色最浅一档 */
|
||||
.sel-item:hover, .sel-item.is-active {
|
||||
background: var(--brand-tint-1, #FAF2EE);
|
||||
color: var(--brand);
|
||||
}
|
||||
/* 选中项:深一档 + 主题色文字 + 右侧对勾,一眼看出当前选的是哪个 */
|
||||
.sel-item.on {
|
||||
background: var(--brand-tint-2, #F3E3DC);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sel-item.on:hover, .sel-item.on.is-active { background: var(--brand-tint-3, #EDD5CB); color: var(--brand); }
|
||||
.sel-item-ck {
|
||||
width: 13px; height: 10px; flex: none;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||
}
|
||||
|
||||
/* 面板里的字段行:下拉要占满整行(与输入框同宽) */
|
||||
.tk-attr-row > .sel { width: 100%; }
|
||||
.tk-attr-row > .sel > .sel-btn { width: 100%; }
|
||||
|
||||
/* 窄屏:至少占满可用宽度,别挤成一小坨 */
|
||||
@media (max-width: 640px) {
|
||||
.sel { flex: 1 1 auto; min-width: 0; }
|
||||
.sel-btn { width: 100%; }
|
||||
}
|
||||
|
||||
+225
-6
@@ -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; }
|
||||
|
||||
+438
-2
@@ -90,14 +90,21 @@
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
}
|
||||
/* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React
|
||||
(Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。
|
||||
详见 Semi 段 `loadJs` 上方的完整说明。 */
|
||||
function loadJs(src) {
|
||||
return new Promise(function (res, rej) {
|
||||
var reg = window.__fbJsLoaded;
|
||||
if (!reg) { reg = window.__fbJsLoaded = {}; }
|
||||
if (reg[src]) { return reg[src]; }
|
||||
reg[src] = new Promise(function (res, rej) {
|
||||
var el = document.createElement('script');
|
||||
el.src = src;
|
||||
el.onload = function () { res(); };
|
||||
el.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||||
document.head.appendChild(el);
|
||||
});
|
||||
return reg[src];
|
||||
}
|
||||
function ensureCropper() {
|
||||
if (window.Cropper) { return Promise.resolve(); }
|
||||
@@ -1597,14 +1604,47 @@
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 加载一个 JS 文件。
|
||||
*
|
||||
* 🔴🔴 2026-11-05 修 React #321(Invalid hook call):
|
||||
*
|
||||
* 症状:首页点任务 → 浮层点「编辑」→ 控制台的 `AntD 日期控件已挂上 1 处`
|
||||
* 紧接着抛 `Minified React error #321`,日期控件崩掉。
|
||||
*
|
||||
* 根因:**React 被加载了两遍**。
|
||||
* · Semi 段的 `boot()` 用 `requestIdleCallback` 延迟跑,
|
||||
* 里面**无条件** `loadJs(conf.react)` + `loadJs(conf.dom)`;
|
||||
* · antd 段的 `ensure()` 只在 `!window.React` 时才加载 —— 看着更"谨慎",
|
||||
* 但两边谁先谁后取决于 idle 回调的时机。
|
||||
* ⇒ antd 先加载、Semi 的 idle 后到 ⇒ Semi **又插了一遍**同一个
|
||||
* `react.min.js` ⇒ 那段 UMD **执行第二次** ⇒ `window.React` 被覆盖。
|
||||
* 而 antd 已经在内部捕获了**第一次**的 React 引用 ⇒ 两套 React 并存
|
||||
* ⇒ antd 的 hook(useContext)在"第二套"的 dispatcher 上调用 ⇒ #321。
|
||||
*
|
||||
* 堆栈里 `VM303 react.min.js` / `VM320 react-dom.min.js` 这种 **VM 前缀**
|
||||
* 就是"同一个文件被 eval 执行了不止一次"的铁证。
|
||||
*
|
||||
* ⇒ 修法:按 `src` **全局去重**,同一个地址永远只执行一次。
|
||||
* 缓存在 `window.__fbJsLoaded`(跨 IIFE 共享 —— 本文件里有两个独立的
|
||||
* `loadJs` 定义,Semi 一个、Semi 外层一个,各自闭包内的变量互相看不见)。
|
||||
*
|
||||
* ⚠️ 不能只给 Semi 那边加判断(`if (!window.React)`):那种写法依赖"现有全局
|
||||
* 是否已经有 React",而 UMD 执行完才赋值 —— 两个请求几乎同时发出时,
|
||||
* 两边都还看不到 `window.React`,照样会各插一个。**按 URL 去重才是确定的。**
|
||||
*/
|
||||
function loadJs(src) {
|
||||
return new Promise(function (res, rej) {
|
||||
var reg = window.__fbJsLoaded;
|
||||
if (!reg) { reg = window.__fbJsLoaded = {}; }
|
||||
if (reg[src]) { return reg[src]; }
|
||||
reg[src] = new Promise(function (res, rej) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = function () { res(); };
|
||||
s.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return reg[src];
|
||||
}
|
||||
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
@@ -2257,6 +2297,19 @@
|
||||
writeDT(box, s); // 写回隐藏四字段
|
||||
if (box.__fbSyncParts) { box.__fbSyncParts(); }
|
||||
box.classList.toggle('has-value', !!s);
|
||||
/* 🔴 #77(用户 2026-11-05):「截止日期的时间保存有bug,时和分没保存上。」
|
||||
真根因:面板保存时读 `.dt-hour` / `.dt-minute` —— 那是**镜像**,
|
||||
依赖本函数的 writeDT 写回。而实测(Playwright + 真实 antd)显示
|
||||
用户在弹层里选完、**不点弹层里的「确定」** 就直接点面板的保存时,
|
||||
镜像还停在上一轮的值(甚至 00:00),于是时分就丢了。
|
||||
|
||||
⇒ 把 onChange 拿到的字符串**直接挂到 box 上**。这是"控件刚刚确认的值",
|
||||
不经任何镜像、不受弹层是否关闭影响。面板保存时优先读它。
|
||||
|
||||
⚠️ 用 `data-fb-dt-value` 而不是自定义 JS 属性:dataset 会同步到 DOM 属性,
|
||||
新节点/重渲染后也能用 `getAttribute` 拿到(虽然本控件是复用同一节点)。
|
||||
⚠️ 空串也要写(= 用户清空了截止时间),所以不能 `if (s !== '')`。 */
|
||||
box.setAttribute('data-fb-dt-value', s);
|
||||
}
|
||||
var v0 = cur();
|
||||
/* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。
|
||||
@@ -2645,3 +2698,386 @@ 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;
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
#85 自绘下拉框(把原生 select 的展开列表换成自绘浮层)
|
||||
══════════════════════════════════════════════════════════════════════
|
||||
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
|
||||
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做。」
|
||||
|
||||
为什么必须自绘:原生 `<select>` 收起时能改样式,但**展开列表由浏览器绘制,
|
||||
CSS 完全改不动**(只能改 option 底色),各平台样式还不统一。
|
||||
|
||||
但**不重写 HTML**:原生 select 留在 DOM 里参与表单提交,我们只在旁边
|
||||
生成一个按钮 + 浮层。好处:
|
||||
· 全站所有 select 自动获得新样式,不用逐个改模板;
|
||||
· `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change);
|
||||
· 无 JS 时原生 select 仍可用(只是朴素),不会白屏。
|
||||
|
||||
⚠️ 浮层 `position: fixed` 挂 body —— 面板/浮层里大量 `overflow:hidden`,
|
||||
放 select 旁边会被裁掉(FeedLite 踩过这个坑)。
|
||||
⚠️ AJAX 换进来的内容(任务面板)要**再次调用** `fbBindSelects()`,
|
||||
否则新 select 没有增强。已在 task_panel.paint / entry_popup.loadPanel 里接上。
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
function fbBindSelects(root) {
|
||||
var scope = root || document;
|
||||
var sels = scope.querySelectorAll('select');
|
||||
for (var i = 0; i < sels.length; i++) {
|
||||
(function (sel) {
|
||||
/* 幂等:已经增强过的跳过(AJAX 重画时会重复调用) */
|
||||
if (sel.getAttribute('data-sel-enhanced') === '1') { return; }
|
||||
/* 明确排除的(比如需要在原位置显示多行列表的) */
|
||||
if (sel.hasAttribute('data-no-enhance')) { return; }
|
||||
/* 多选 / 列表框不动 —— 那些本来就有自己的多行展示方式 */
|
||||
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
|
||||
/* 隐藏域也不要(有些地方用它传值) */
|
||||
if (sel.style.display === 'none' || sel.hidden) { return; }
|
||||
|
||||
sel.setAttribute('data-sel-enhanced', '1');
|
||||
sel.classList.add('sel-native');
|
||||
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'sel';
|
||||
sel.parentNode.insertBefore(wrap, sel);
|
||||
wrap.appendChild(sel);
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sel-btn';
|
||||
btn.setAttribute('aria-haspopup', 'listbox');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
wrap.appendChild(btn);
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'sel-panel';
|
||||
panel.hidden = true;
|
||||
panel.setAttribute('role', 'listbox');
|
||||
document.body.appendChild(panel);
|
||||
|
||||
var active = -1;
|
||||
|
||||
function curText() {
|
||||
var o = sel.options[sel.selectedIndex];
|
||||
return o ? o.textContent : '';
|
||||
}
|
||||
function syncBtn() {
|
||||
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
|
||||
btn.querySelector('.sel-txt').textContent = curText();
|
||||
btn.disabled = sel.disabled;
|
||||
/* 首项值为空 = 占位项(未分类 / 不循环),文字用次要色 */
|
||||
btn.classList.toggle('is-placeholder',
|
||||
sel.selectedIndex <= 0 && sel.options.length > 0 && sel.options[0].value === '');
|
||||
}
|
||||
|
||||
function buildList() {
|
||||
panel.innerHTML = '';
|
||||
for (var k = 0; k < sel.options.length; k++) {
|
||||
(function (idx) {
|
||||
var o = sel.options[idx];
|
||||
if (o.hidden) { return; }
|
||||
var item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'sel-item';
|
||||
item.setAttribute('role', 'option');
|
||||
var on = idx === sel.selectedIndex;
|
||||
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
|
||||
var label = document.createElement('span');
|
||||
label.className = 'sel-item-txt';
|
||||
label.textContent = o.textContent;
|
||||
item.appendChild(label);
|
||||
if (on) {
|
||||
var ck = document.createElement('span');
|
||||
ck.className = 'sel-item-ck';
|
||||
ck.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(ck);
|
||||
}
|
||||
item.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pick(idx);
|
||||
});
|
||||
panel.appendChild(item);
|
||||
})(k);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(idx) {
|
||||
if (idx < 0 || idx >= sel.options.length) { return; }
|
||||
var changed = sel.selectedIndex !== idx;
|
||||
sel.selectedIndex = idx;
|
||||
syncBtn();
|
||||
close();
|
||||
/* 派发原生 change —— 兼容 onchange="this.form.submit()" 那种写法 */
|
||||
if (changed) {
|
||||
var ev = document.createEvent('HTMLEvents');
|
||||
ev.initEvent('change', true, false);
|
||||
sel.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (btn.disabled) { return; }
|
||||
buildList();
|
||||
panel.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
btn.classList.add('is-open');
|
||||
/* 先量宽度再定位:panel 刚显示时 offsetWidth 才有值 */
|
||||
position();
|
||||
active = sel.selectedIndex;
|
||||
highlight(active);
|
||||
document.addEventListener('mousedown', outside, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('scroll', position, true);
|
||||
window.addEventListener('resize', position);
|
||||
}
|
||||
function close() {
|
||||
if (panel.hidden) { return; }
|
||||
panel.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.classList.remove('is-open');
|
||||
document.removeEventListener('mousedown', outside, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
window.removeEventListener('resize', position);
|
||||
}
|
||||
function outside(e) {
|
||||
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
|
||||
}
|
||||
|
||||
/* 定位:fixed 挂 body,不服从任何祖先的 overflow。
|
||||
优先向下展开;下方不够且上方更宽裕时向上翻。 */
|
||||
function position() {
|
||||
if (panel.hidden) { return; }
|
||||
var r = btn.getBoundingClientRect();
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var pw = Math.max(panel.offsetWidth, r.width);
|
||||
var ph = panel.offsetHeight;
|
||||
var margin = 8;
|
||||
|
||||
var left = r.left;
|
||||
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
|
||||
|
||||
var below = vh - r.bottom - margin;
|
||||
var above = r.top - margin;
|
||||
var openUp = (ph > below) && (above > below);
|
||||
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
|
||||
if (top < margin) { top = margin; }
|
||||
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
|
||||
|
||||
panel.style.left = Math.round(left) + 'px';
|
||||
panel.style.top = Math.round(top) + 'px';
|
||||
/* 与触发按钮同宽(用户要求"和输入框一样宽") */
|
||||
panel.style.minWidth = Math.round(r.width) + 'px';
|
||||
panel.classList.toggle('is-up', openUp);
|
||||
}
|
||||
|
||||
function highlight(idx) {
|
||||
var items = panel.querySelectorAll('.sel-item');
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
items[k].classList.toggle('is-active', k === idx);
|
||||
}
|
||||
if (idx >= 0 && items[idx]) {
|
||||
var it = items[idx];
|
||||
var top = it.offsetTop, bot = top + it.offsetHeight;
|
||||
if (top < panel.scrollTop) { panel.scrollTop = top; }
|
||||
else if (bot > panel.scrollTop + panel.clientHeight) {
|
||||
panel.scrollTop = bot - panel.clientHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
var items = panel.querySelectorAll('.sel-item');
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
active = Math.min(items.length - 1, active + 1); highlight(active);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
active = Math.max(0, active - 1); highlight(active);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (active >= 0) { pick(active); }
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault(); active = 0; highlight(active);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault(); active = items.length - 1; highlight(active);
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (panel.hidden) { open(); } else { close(); }
|
||||
});
|
||||
|
||||
/* 原生 select 被程序改值时,按钮文字要跟着变 */
|
||||
sel.addEventListener('change', syncBtn);
|
||||
|
||||
syncBtn();
|
||||
})(sels[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* 对外暴露:AJAX 换进来的内容要再调一次 */
|
||||
window.fbBindSelects = fbBindSelects;
|
||||
|
||||
|
||||
|
||||
/* #85:首屏自动增强所有 select */
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { fbBindSelects(document); });
|
||||
} else {
|
||||
fbBindSelects(document);
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -139,7 +139,15 @@
|
||||
*/
|
||||
function paintDetail(data) {
|
||||
var isEvent = (data.source === 'event');
|
||||
if (titleEl()) { titleEl().textContent = data.title || '详情'; }
|
||||
/* 🔴 #83(用户 2026-11-05):「最左上角也是一样,应该显示"编辑任务",
|
||||
而不是显示任务的标题。」
|
||||
|
||||
任务面板(带 `data.panel`)本身就是**编辑面板** —— 标题栏写任务标题
|
||||
会让用户以为"这是详情页,只是恰好能改"。
|
||||
其它来源(观影/旅程/日程/纪念日)仍显示各自的标题:
|
||||
它们点开的确实是"这条记录的详情",显示名字才是对的。 */
|
||||
var headText = data.panel ? '编辑任务' : (data.title || '详情');
|
||||
if (titleEl()) { titleEl().textContent = headText; }
|
||||
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
|
||||
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
|
||||
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
|
||||
@@ -176,7 +184,16 @@
|
||||
/* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
|
||||
所以这两颗**只对 source='event'** 显(且各要有地址)。 */
|
||||
var ed = byId('fbEntryEdit');
|
||||
if (ed) { ed.hidden = !(isEvent && data.edit); }
|
||||
/* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。
|
||||
所以不需要再加一个编辑按钮了。」
|
||||
|
||||
⇒ 任务(带 `panel`)的面板里已经能改**全部**字段
|
||||
(标题/截止/负责人/协作人/分类/备注 + 状态/看板/子任务/评论/附件),
|
||||
再给一颗「编辑」只会让用户以为"是不是还有别的东西要进另一个页面改"。
|
||||
⚠️ **不能无条件藏**:观影 / 旅程 / 日程 / 纪念日 那些来源没有 panel,
|
||||
它们仍然只有「详情 + 编辑」,那颗按钮必须留着。
|
||||
⚠️ `data.del`(删除)不动 —— 用户没要求改删除,保持原样。 */
|
||||
if (ed) { ed.hidden = !(isEvent && data.edit) || !!data.panel; }
|
||||
var de = byId('fbEntryDelete');
|
||||
if (de) { de.hidden = !(isEvent && data.del); }
|
||||
|
||||
@@ -208,6 +225,41 @@
|
||||
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
|
||||
bodyEl().innerHTML = html;
|
||||
runInlineScripts(bodyEl(), html);
|
||||
/* 🔴🔴 #74(用户 2026-11-05):「空间有了,但是不是我想要的空间,这个空间需要换。」
|
||||
—— 实为「控件」:面板里的截止时间显示成**五个原生下拉**(年/月/日/时/分),
|
||||
而不是 AntD 的弹层日历。
|
||||
|
||||
真根因:**首次打开面板走的是这条 loadPanel()**,它只 innerHTML + 跑内联脚本,
|
||||
**没有补挂 AntD**。
|
||||
我在 task_panel.js 的 paint() 里加过 fbBootAntd(),但那只管
|
||||
"面板里做了动作之后的重画"——**首次打开不经过 paint()**。
|
||||
⇒ 用户点开任务看到的是从未被升级过的原生下拉。
|
||||
|
||||
⚠️ 与上面 loadEdit() 里那次调用同一个理由:AJAX 换进来的 HTML
|
||||
不会自己跑初始化;bootAntd() 只在页面加载时扫过一次
|
||||
[data-dt-antd-single],那时面板还不存在。
|
||||
⚠️ 放在 runInlineScripts 之后:内联脚本里可能也有初始化逻辑,
|
||||
让它先跑完;mountAntd* 有 data-antd-done 幂等标记,重复调用无害。 */
|
||||
if (typeof window.fbBootAntd === 'function') {
|
||||
try { window.fbBootAntd(); } catch (e) { /* 挂不上就留在原生下拉,不阻断面板 */ }
|
||||
/* #85:AJAX 换进来的 select 也要增强成自绘下拉 */
|
||||
if (typeof window.fbBindSelects === 'function') {
|
||||
try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
/* 🔴 #78(用户 2026-11-05):「选人的时候不能按列表,因为人太多了。
|
||||
你参考一下观影模式里面选人的方式。」
|
||||
⇒ 面板里的负责人/协作人改用 `_person_picker.php`(弹窗 + 拼音联想),
|
||||
那个组件靠 `fbInitPickers(容器的)` 接管点击。
|
||||
`loadEdit()` 里一直有这一句(第 315 行),**`loadPanel()` 漏了** ——
|
||||
所以面板里的选人按钮点了没反应。
|
||||
⚠️ 组件自带 `data-pp-init` 幂等标记,重复调用不会重复绑。 */
|
||||
if (typeof window.fbLoadPicker === 'function') {
|
||||
try { window.fbLoadPicker(); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
if (typeof window.fbInitPickers === 'function') {
|
||||
try { window.fbInitPickers(bodyEl()); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (!bodyEl()) { return; }
|
||||
|
||||
+64
-5
@@ -70,6 +70,8 @@
|
||||
var kind = trigger.getAttribute('data-prev') || 'image';
|
||||
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
||||
var dl = trigger.getAttribute('data-dl') || src;
|
||||
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
|
||||
var nm2 = trigger.getAttribute('data-name') || '';
|
||||
if (!src) { return false; }
|
||||
opener = trigger;
|
||||
clear();
|
||||
@@ -82,6 +84,37 @@
|
||||
el.controls = true;
|
||||
el.autoplay = true;
|
||||
el.playsInline = true;
|
||||
/* 🔴🔴 #53(2026-11-05):**HEVC 解码失败时给明确提示,不要装死**。
|
||||
实测用户的视频是 H.265/HEVC(`hvc1`),Chrome/Firefox 的根本解不了 ——
|
||||
表现为一块黑屏,什么提示都没有,用户只会以为"文件坏了"。
|
||||
|
||||
这里监听 error:能取到 `el.error.code` 就说明是解码/加载失败。
|
||||
把解释写进画面本身(而不是 alert),并给出下载入口 ——
|
||||
下载下来用系统播放器是能看的(VLC/iPhone/Windows 都支持 HEVC)。
|
||||
|
||||
⚠️ 不做"静默回退到下载页":用户点的是"看",直接跳走会困惑。
|
||||
要的是**在原地说明白**,同时给一条出路。 */
|
||||
el.addEventListener('error', function () {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'pv-fallback';
|
||||
var h = document.createElement('p');
|
||||
h.className = 'pv-fallback-h';
|
||||
h.textContent = '这个视频浏览器解不了';
|
||||
var p = document.createElement('p');
|
||||
p.className = 'pv-fallback-p';
|
||||
p.textContent = '它可能是 H.265(HEVC)编码。Chrome、Firefox 不支持这种格式,'
|
||||
+ '但文件本身没坏 —— 下载后用系统播放器(iPhone / VLC / Windows 播放器)可以正常观看。';
|
||||
var a = document.createElement('a');
|
||||
a.className = 'pv-fallback-dl';
|
||||
a.href = dl;
|
||||
a.textContent = '下载这个视频';
|
||||
if (nm2) { a.setAttribute('download', nm2); }
|
||||
wrap.appendChild(h);
|
||||
wrap.appendChild(p);
|
||||
wrap.appendChild(a);
|
||||
while (stage.firstChild) { stage.removeChild(stage.firstChild); }
|
||||
stage.appendChild(wrap);
|
||||
});
|
||||
} else if (kind === 'pdf') {
|
||||
el = document.createElement('iframe');
|
||||
el.className = 'pv-pdf';
|
||||
@@ -153,9 +186,28 @@
|
||||
cur = -1;
|
||||
}
|
||||
|
||||
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */
|
||||
function isImage() {
|
||||
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
|
||||
/**
|
||||
* 按键是否发生在**媒体控件内部**(视频 / PDF iframe)。
|
||||
*
|
||||
* 🔴 #58(用户 2026-11-05):「行程里面图片轮播的时候,切换到视频,
|
||||
* 上下左右键就失效了。」
|
||||
*
|
||||
* 原来的写法是 `if (e.key === 'ArrowLeft' && isImage())` ——
|
||||
* 只有**当前是图片**才翻页,切到视频后左右键直接失效。
|
||||
*
|
||||
* 当年这么写的顾虑(注释原文)是「视频/PDF 里方向键另有含义,不能抢」,
|
||||
* 这个顾虑对:视频的左右键是快退/快进,PDF 里是翻页。
|
||||
*
|
||||
* 但**一刀切禁掉**是错的解法。正确做法是看**事件目标**:
|
||||
* · 按键发生在 video/iframe 内部 ⇒ 让它自己处理(快进、翻页)
|
||||
* · 按键发生在别处(背景、标题栏)⇒ 照常翻页
|
||||
*
|
||||
* ⚠️ 视频**没聚焦**时 `e.target` 往往是 `<body>` ——
|
||||
* 这时用户没在操作视频,就是想换下一张,应该翻页。
|
||||
*/
|
||||
function inMediaControl(t) {
|
||||
if (!t || !t.closest) { return false; }
|
||||
return !!t.closest('video, iframe');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
@@ -177,7 +229,14 @@
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (box.hidden) { return; }
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
|
||||
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); }
|
||||
|
||||
/* #58:焦点在视频/PDF 控件里 ⇒ 放行给控件(快进、PDF 翻页)。
|
||||
这样"视频里左右键是快进"和"轮播里左右键是翻页"**不冲突**。 */
|
||||
if (inMediaControl(e.target)) { return; }
|
||||
|
||||
/* 其余情况:左右键翻页 —— **不再限制只有图片才能翻**。
|
||||
⚠️ 上下键保持不处理:页面滚动/视频音量另有含义,抢了会别扭。 */
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); return; }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
|
||||
});
|
||||
})();
|
||||
|
||||
+399
-3
@@ -78,6 +78,29 @@
|
||||
var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
|
||||
if (ti2) { ti2.value = draftTitle; }
|
||||
}
|
||||
/* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息,
|
||||
包括…修改截止时间。」
|
||||
|
||||
⇒ 面板里现在有 AntD 的单点日期控件(`[data-dt-antd-single]`)。
|
||||
这段 HTML 是 AJAX 换进来的,**不会自己跑初始化** ——
|
||||
`bootAntd()` 只在页面加载时扫过一次 `[data-dt-antd-single]`,
|
||||
那时面板还不存在 ⇒ 控件会是一片原生下拉(或空白)。
|
||||
⇒ 每次重画面板都要补挂一次。
|
||||
|
||||
⚠️ `mountAntd*` 有 `data-antd-done` 幂等标记,重复调用不会挂两遍。
|
||||
⚠️ 必须放在 `innerHTML` 之后 —— 要挂的节点得先真的在 DOM 里。 */
|
||||
if (typeof window.fbBootAntd === 'function') {
|
||||
try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ }
|
||||
/* #85:AJAX 换进来的 select 也要增强成自绘下拉 */
|
||||
if (typeof window.fbBindSelects === 'function') {
|
||||
try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
/* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。
|
||||
不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */
|
||||
if (typeof window.fbInitPickers === 'function') {
|
||||
try { window.fbInitPickers(b); } catch (err) { /* 忽略 */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -112,6 +135,36 @@
|
||||
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
||||
if (!row) { return; }
|
||||
|
||||
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
|
||||
根因:这个函数原来**只管状态徽标和复选框**,不碰标题。
|
||||
而 #78 之后面板里能改标题了 ⇒ 改完标题点保存,卡片上还是旧标题。
|
||||
⇒ 补上标题同步:`.dash-what` 的文本 + 它的 `title` 属性
|
||||
(`title` 是鼠标悬停提示,不同步会出现"悬停是旧标题"的怪事)。
|
||||
⚠️ 只在服务端真的回了 `title` 时才改 —— `task_panel_json()` 目前
|
||||
不带这个字段(见 app/helpers.php),所以下面还有一道兜底:
|
||||
从当前面板的输入框里读。两条路任一命中即可。 */
|
||||
var newTitle = (typeof d.title === 'string' && d.title !== '') ? d.title : '';
|
||||
if (newTitle === '') {
|
||||
var ti = bodyEl() ? bodyEl().querySelector('#tkp-title') : null;
|
||||
if (ti) { newTitle = String(ti.value || '').trim(); }
|
||||
}
|
||||
if (newTitle !== '') {
|
||||
var what = row.querySelector('.dash-what');
|
||||
if (what) {
|
||||
what.textContent = newTitle;
|
||||
what.setAttribute('title', newTitle);
|
||||
}
|
||||
/* 浮层下次打开时读的是 `data-fb-entry` 里的 title —— 一并改掉,
|
||||
否则"卡片上是新标题、再点开浮层显示旧标题"。 */
|
||||
if (what && what.getAttribute('data-fb-entry')) {
|
||||
try {
|
||||
var ent = JSON.parse(what.getAttribute('data-fb-entry'));
|
||||
ent.title = newTitle;
|
||||
what.setAttribute('data-fb-entry', JSON.stringify(ent));
|
||||
} catch (e) { /* JSON 坏了就跳过,不阻断 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* 🔴 必须在改写 `data-tk-status` **之前**读 —— 见上面最后一条 ⚠️。 */
|
||||
var chk0 = row.querySelector('.tk-check[data-tk-done]');
|
||||
var was = chk0 ? (chk0.getAttribute('data-tk-status') || 'todo') : '';
|
||||
@@ -140,7 +193,13 @@
|
||||
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
|
||||
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
|
||||
*/
|
||||
function send(url, fd, keepDraft) {
|
||||
/**
|
||||
* 发请求并刷新面板。
|
||||
*
|
||||
* @param keepDraft #78:重画后保住用户没发出去的评论草稿(传附件时用)
|
||||
* @param closeOnOk #78:成功后**关掉浮层**(「保存」用;其它动作不关)
|
||||
*/
|
||||
function send(url, fd, keepDraft, closeOnOk) {
|
||||
if (!url) { setTip('这一项暂时不能在首页改。', true); return; }
|
||||
if (!window.fetch || !window.FormData) { window.location.reload(); return; }
|
||||
fetch(url, {
|
||||
@@ -152,6 +211,20 @@
|
||||
return r.json().catch(function () { return null; });
|
||||
}).then(function (d) {
|
||||
if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实
|
||||
/* 🔴 #78(用户 2026-11-05):「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
|
||||
⇒ 保存成功就关窗,不再重画面板(用户已经不在看了)。
|
||||
⚠️ 关窗前**先更新背后那张首页卡片**:否则用户关掉浮层看到的还是旧标题/旧状态,
|
||||
"改了没生效"的错觉比不关窗更糟。
|
||||
⚠️ 失败(d.ok === false)时**不关** —— 让用户看到错误提示、就地改。 */
|
||||
if (closeOnOk && d.ok !== false) {
|
||||
syncRow(d);
|
||||
if (typeof window.fbEntryClose === 'function') {
|
||||
window.fbEntryClose();
|
||||
} else if (typeof window.fbCloseEntry === 'function') {
|
||||
window.fbCloseEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
paint(d, keepDraft);
|
||||
syncRow(d);
|
||||
setTip(d.msg || '', !d.ok);
|
||||
@@ -159,13 +232,23 @@
|
||||
}
|
||||
|
||||
/** 把「id + 几个附加字段」包成一个动作请求 */
|
||||
function act(url, extra) {
|
||||
/**
|
||||
* 发一个面板动作。
|
||||
*
|
||||
* @param url 目标地址
|
||||
* @param extra 额外字段
|
||||
* @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。
|
||||
* 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
|
||||
* ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件
|
||||
* 都还要继续在这个面板里操作,关窗会把用户赶走。
|
||||
*/
|
||||
function act(url, extra, closeOnOk) {
|
||||
var fd = new FormData();
|
||||
fd.append('_csrf', csrf());
|
||||
fd.append('id', taskId());
|
||||
fd.append('ajax_panel', '1');
|
||||
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
|
||||
send(url, fd);
|
||||
send(url, fd, false, closeOnOk);
|
||||
}
|
||||
|
||||
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
|
||||
@@ -221,6 +304,180 @@
|
||||
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
|
||||
return;
|
||||
}
|
||||
/* 🔴 #76(用户 2026-11-05):「时间控件是对的,但是现在没有保存按钮…
|
||||
另外,负责人和分类都没法修改,按道理讲都应该可以修改才对。」
|
||||
|
||||
⇒ 「截止 / 负责人 / 分类」三项一次性提交。
|
||||
⚠️ 上一版是"选完截止时间立即保存"(监听 change),用户不认可 ——
|
||||
他要的是"改完点保存"这种确定感。
|
||||
⚠️ 只提交这三项,服务端 `Task::setAttrs()` 也只写这三列,
|
||||
不碰 title/note/status/repeat(见那个方法的注释)。 */
|
||||
/**
|
||||
* 读出「截止时间」框当前值 → `YYYY-MM-DD HH:mm:ss`(#77)。
|
||||
*
|
||||
* 🔴 用户 2026-11-05:「截止日期的时间保存有bug,时和分没保存上。」
|
||||
*
|
||||
* 为什么不能只读 `.dt-hour` / `.dt-minute`:
|
||||
* AntD 一旦挂上(`.dt-input.is-antd`),`.dt-native` 被 CSS 收成
|
||||
* `display:none`。那几档原生 select 里的值是**控件写回来的镜像**,
|
||||
* 而镜像的写入依赖 `mountAntdSingleDT.writeBack()`:
|
||||
* onChange(val, str) → writeDT(box, str)
|
||||
* `str` 来自 antd 的 `format: 'YYYY-MM-DD HH:mm'`。
|
||||
* 实测 antd 5 在**受控**模式下(我们传了 `value`),
|
||||
* 某些路径给的是 `val`(dayjs 对象)而 `str` 可能是空串 ——
|
||||
* 此时 writeDT 走进 else 分支,把 .dt-date/.dt-hour/.dt-minute **全清成空/00**。
|
||||
* ⇒ 日期还有值(另一条路径补齐了)、时分却恒为 00,正是用户报的现象。
|
||||
*
|
||||
* ⇒ 读值时**以用户看得见的那个框为准**:
|
||||
* ① 先看 AntD 的可见 input(`.ant-picker-input > input`)里的文本,
|
||||
* 它的格式就是 `YYYY-MM-DD HH:mm`;
|
||||
* ② 没有 AntD(降级成原生下拉)时才读隐藏字段;
|
||||
* ③ 都没有就当空。
|
||||
*
|
||||
* ⚠️ AntD 的输入框是**只读文本框**(用户通过弹层选,不直接打字),
|
||||
* 所以解析它的 text 是安全的 —— 不会出现用户在框里敲了半截日期的中间态。
|
||||
*/
|
||||
/**
|
||||
* 替用户点掉 AntD 弹层的「确定」(#77)。
|
||||
*
|
||||
* 🔴 为什么必须点:antd 的 `DatePicker` + `showTime` **只有点它内部的
|
||||
* 「确定」才会提交值**。点别处(包括点我们面板的「保存」)会被当成取消,
|
||||
* 值**弹回上一次确认的值**。
|
||||
* 实测:选完 2026-11-16 20:34(未点确定)→ 点 body → 50ms 后
|
||||
* 可见框弹回 "2026-11-07 00:00",onChange 一次都没触发。
|
||||
* 这正是用户报的「时和分没保存上」(库里变成 00:00:00)。
|
||||
*
|
||||
* ⚠️ 只在弹层真的开着时才点(`:not(.ant-picker-dropdown-hidden)`),
|
||||
* 否则会点到别的东西。
|
||||
*/
|
||||
function commitAntd() {
|
||||
var drop = document.querySelector('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)');
|
||||
if (!drop) { return false; }
|
||||
var okBtn = drop.querySelector('.ant-picker-ok button');
|
||||
if (okBtn && !okBtn.disabled) {
|
||||
okBtn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readDue(box) {
|
||||
if (!box) { return ''; }
|
||||
/* 🔴🔴 第 ⓪ 步(**最可靠**,优先于其它一切):读 antd 的 onChange 直挂值。
|
||||
`app.js` 的 `mountAntdSingleDT.writeBack()` 现在会把每次 onChange 拿到的
|
||||
字符串写到 `box[data-fb-dt-value]`。那是"控件刚刚确认的值",
|
||||
**不经任何镜像、不受弹层是否关闭影响**。
|
||||
实测过的那两种读法都有坑:
|
||||
· 读 .dt-hour/.dt-minute(镜像)→ 用户没点弹层「确定」时停在旧值/00;
|
||||
· 读可见框 → 弹层未提交时是半截值(20:34 而不是 20:30)。
|
||||
⇒ 有 data-fb-dt-value 就用它,一步到位。 */
|
||||
var dv = box.getAttribute('data-fb-dt-value');
|
||||
if (dv !== null) {
|
||||
var dvt = String(dv).trim();
|
||||
if (dvt === '') { return ''; } /* 用户清空了 */
|
||||
var dmv = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||||
if (dmv) {
|
||||
var pd = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return dmv[1] + '-' + pd(+dmv[2]) + '-' + pd(+dmv[3]) + ' '
|
||||
+ pd(+dmv[4]) + ':' + pd(+dmv[5]) + ':' + pd(+(dmv[6] || 0));
|
||||
}
|
||||
var dmd = dvt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (dmd) {
|
||||
var pd2 = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return dmd[1] + '-' + pd2(+dmd[2]) + '-' + pd2(+dmd[3]) + ' 00:00:00';
|
||||
}
|
||||
}
|
||||
/* ⚠️ 「点确定」不在这里做 —— 它是 `commitAntd()` + 等一帧之后才读。
|
||||
原因见 save-attrs 分支上的长注释:antd 的 setState→重渲染在微任务里,
|
||||
同步读拿到的是提交前那一帧。 */
|
||||
/* ① AntD 可见框 */
|
||||
var vis = box.querySelector('.ant-picker-input > input');
|
||||
if (vis) {
|
||||
var vt = String(vis.value || '').trim();
|
||||
if (vt !== '') {
|
||||
/* 已选:'2026-12-31 20:00' 或 '2026-12-31 20:00:00' */
|
||||
var mv = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||||
if (mv) {
|
||||
var p2 = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return mv[1] + '-' + p2(+mv[2]) + '-' + p2(+mv[3]) + ' '
|
||||
+ p2(+mv[4]) + ':' + p2(+mv[5]) + ':' + p2(+(mv[6] || 0));
|
||||
}
|
||||
/* 只有日期没时间(用户只选了日子就点保存):补 00:00 */
|
||||
var md = vt.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (md) {
|
||||
var p3 = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return md[1] + '-' + p3(+md[2]) + '-' + p3(+md[3]) + ' 00:00:00';
|
||||
}
|
||||
}
|
||||
/* AntD 框存在但空着 = 用户清空了截止时间 */
|
||||
return '';
|
||||
}
|
||||
/* ② 降级形态:读隐藏字段(与 app.js 的 readDT 同一口径) */
|
||||
var dt = box.querySelector('.dt-date');
|
||||
var dstr = dt ? String(dt.value || '').trim() : '';
|
||||
if (dstr === '') { return ''; }
|
||||
var hh = box.querySelector('.dt-hour');
|
||||
var mm = box.querySelector('.dt-minute');
|
||||
var h2 = hh && String(hh.value || '').trim() !== '' ? String(hh.value) : '00';
|
||||
var m2 = mm && String(mm.value || '').trim() !== '' ? String(mm.value) : '00';
|
||||
return dstr + ' ' + h2 + ':' + m2 + ':00';
|
||||
}
|
||||
|
||||
if (what === 'save-attrs') {
|
||||
e.preventDefault();
|
||||
if (btn.disabled) { return; }
|
||||
var bd = bodyEl();
|
||||
if (!bd) { return; }
|
||||
var dbox = bd.querySelector('.dt-input[data-dt]');
|
||||
var asg = bd.querySelector('select[name="assignee_id"]');
|
||||
var cat = bd.querySelector('select[name="category_id"]');
|
||||
|
||||
btn.disabled = true;
|
||||
/* 🔴🔴 保存前的两步(顺序不能变):
|
||||
① `commitAntd()` 替用户点掉 antd 弹层的「确定」—— 不点就**弹回原值**;
|
||||
② **等一帧再读** —— antd 的 onChange → setState → 重渲染落在微任务/下一帧,
|
||||
同步读拿到的是提交前那一帧(实测差几分钟,或者干脆是旧值)。
|
||||
|
||||
实测对照(Playwright + 真实 antd):
|
||||
不点确定、点保存 → readDue="2026-11-07 00:00:00"(原值,修改丢失)
|
||||
点确定后等 60ms → readDue="2026-11-16 20:30:00" ✅
|
||||
60ms 用户完全无感,足够 React 把一帧画完 + writeBack 写好镜像。 */
|
||||
commitAntd();
|
||||
setTimeout(function () {
|
||||
var extra = {};
|
||||
extra['due_at'] = readDue(dbox);
|
||||
/* ⚠️ 只有控件真的在 DOM 里才提交该项 —— 控件不渲染时不该把原值清掉。 */
|
||||
if (asg) { extra['assignee_id'] = asg.value; }
|
||||
if (cat) { extra['category_id'] = cat.value; }
|
||||
|
||||
/* #78:标题 / 备注 —— 删掉「编辑」按钮后,它们只能在这里改 */
|
||||
var ti = bd.querySelector('#tkp-title');
|
||||
if (ti) { extra['title'] = ti.value; }
|
||||
var nt = bd.querySelector('#tkp-note');
|
||||
if (nt) { extra['note'] = nt.value; }
|
||||
|
||||
/* #78:协作人(`_person_picker.php` 的 multi 选人把结果存在
|
||||
隐藏 checkbox 里,name="shares[]")。逐个收集。
|
||||
⚠️ 必须带 `shares_submitted=1` 这个哨兵:
|
||||
服务端靠它区分"表单里根本没有协作人控件"(不动)与
|
||||
"用户把它们全删了"(清空)—— 只靠 `shares[]` 是否存在,
|
||||
空数组时 PHP 收不到任何键,无法区分。 */
|
||||
var shareBox = bd.querySelector('input[name="shares[]"]');
|
||||
if (shareBox) {
|
||||
extra['shares_submitted'] = '1';
|
||||
var picked = bd.querySelectorAll('input[name="shares[]"]:checked');
|
||||
for (var i = 0; i < picked.length; i++) {
|
||||
extra['shares[' + i + ']'] = picked[i].value;
|
||||
}
|
||||
}
|
||||
|
||||
act(urlOf('save'), extra, true);
|
||||
}, 60);
|
||||
/* 面板会被整块换掉(act → send → paint),按钮随之重建,不用手动恢复 */
|
||||
setTimeout(function () { btn.disabled = false; }, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (what === 'cmt-del') {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('删掉这条留言?')) { return; }
|
||||
@@ -267,6 +524,144 @@
|
||||
if (inp) { inp.click(); }
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
#79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
|
||||
|
||||
与上面「任务附件」的拖拽是**两条独立的路**,别混:
|
||||
· `[data-tk-drop-box]` = 点了/拖了**立刻上传**(任务已经存在,传上去就生效);
|
||||
· `[data-cmt-drop]` = 把文件挂到评论表单上,**点「发表」才一起提交**
|
||||
(评论是先写文字再发,附件跟着这条评论走)。
|
||||
|
||||
🔴 核心技术点:`<input type=file>.files` 是**只读**的,
|
||||
不能 `input.files = event.dataTransfer.files`(直接赋值在某些浏览器
|
||||
会静默失败)。必须用 `DataTransfer` 重建一个 FileList 赋回去:
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files; ← 这个赋值是允许的
|
||||
而且**必须真的赋给 input**,否则只是"看起来加上了"、
|
||||
表单提交时浏览器一个文件都不带。
|
||||
|
||||
⚠️ 支持**多次拖入累加**:用户可能先拖一张再拖一张。
|
||||
每次取 `input.files` 现有内容 + 新拖进来的,合并后写回。
|
||||
⚠️ 同一文件重复拖入要跳过(按 name+size+lastModified 判重),
|
||||
否则会出现"我明明只拖了一次,传上去两张一样的"。
|
||||
============================================================ */
|
||||
function cmtDropZone(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) { return null; }
|
||||
return t.closest('[data-cmt-drop]');
|
||||
}
|
||||
|
||||
function cmtFileInput(zone) {
|
||||
return zone ? zone.querySelector('[data-cmt-file-input]') : null;
|
||||
}
|
||||
|
||||
/** 把 files 合并进 input(累加 + 去重),并更新旁边那行文件名提示 */
|
||||
function cmtAddFiles(zone, files) {
|
||||
var input = cmtFileInput(zone);
|
||||
if (!input || !files || !files.length) { return; }
|
||||
|
||||
var dt;
|
||||
try { dt = new DataTransfer(); } catch (e) { return; } // 老浏览器:放弃,用户仍可点选
|
||||
|
||||
/* 先把已有的放进去(累加语义) */
|
||||
var seen = {};
|
||||
var add = function (f) {
|
||||
if (!f) { return; }
|
||||
var key = f.name + '|' + f.size + '|' + (f.lastModified || 0);
|
||||
if (seen[key]) { return; }
|
||||
seen[key] = 1;
|
||||
dt.items.add(f);
|
||||
};
|
||||
if (input.files) {
|
||||
for (var i = 0; i < input.files.length; i++) { add(input.files[i]); }
|
||||
}
|
||||
for (var j = 0; j < files.length; j++) { add(files[j]); }
|
||||
|
||||
try { input.files = dt.files; } catch (e) { /* 个别内核只读:下面提示照旧 */ }
|
||||
|
||||
/* 更新已选文件列表(用户要能确认"确实加上了哪几个")。
|
||||
#81:从"一行逗号拼接"改成"一行一个" —— 拖 5 个文件时
|
||||
逗号拼起来读不清,也看不出到底几个。 */
|
||||
cmtRenderNames(zone, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `input.files` 渲染成一行一个的列表(#81)。
|
||||
*
|
||||
* ⚠️ 用 `textContent` 逐个 append,**不要** `innerHTML +=` 拼字符串 ——
|
||||
* 文件名是用户可控的(`<img onerror=...>.jpg` 这种),拼进 innerHTML
|
||||
* 就是 XSS。用 `document.createElement('span')` + `textContent` 才安全。
|
||||
* ⚠️ 列表为空时 `hidden`,非空才显示 —— 否则评论区永远挂着一块空灰条。
|
||||
*/
|
||||
function cmtRenderNames(zone, input) {
|
||||
var box = zone.querySelector('[data-cmt-file-name]');
|
||||
if (!box) { return; }
|
||||
while (box.firstChild) { box.removeChild(box.firstChild); }
|
||||
var n = input && input.files ? input.files.length : 0;
|
||||
if (!n) {
|
||||
box.hidden = true;
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < n; i++) {
|
||||
var sp = document.createElement('span');
|
||||
sp.textContent = input.files[i].name;
|
||||
box.appendChild(sp);
|
||||
}
|
||||
box.hidden = false;
|
||||
}
|
||||
|
||||
/* dragover:必须 preventDefault,否则浏览器把文件当"打开"处理(页面跳走) */
|
||||
document.addEventListener('dragover', function (e) {
|
||||
var zone = cmtDropZone(e);
|
||||
if (!zone) { return; }
|
||||
e.preventDefault();
|
||||
zone.classList.add('is-over');
|
||||
});
|
||||
document.addEventListener('dragenter', function (e) {
|
||||
var zone = cmtDropZone(e);
|
||||
if (!zone) { return; }
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('dragleave', function (e) {
|
||||
var zone = cmtDropZone(e);
|
||||
if (!zone) { return; }
|
||||
/* 只有真的离开这片区域才去掉高亮(进出子元素也会触发 dragleave) */
|
||||
if (!zone.contains(e.relatedTarget)) {
|
||||
zone.classList.remove('is-over');
|
||||
}
|
||||
});
|
||||
document.addEventListener('drop', function (e) {
|
||||
var zone = cmtDropZone(e);
|
||||
if (!zone) { return; }
|
||||
e.preventDefault(); // 不拦的话浏览器会直接打开这个文件
|
||||
zone.classList.remove('is-over');
|
||||
var files = e.dataTransfer && e.dataTransfer.files;
|
||||
cmtAddFiles(zone, files);
|
||||
});
|
||||
|
||||
/* #81:新的上传框带 `role="button" tabindex="0"`(`.upbox` 的既有约定),
|
||||
键盘 Enter/Space 也要能打开文件选择 —— 只做鼠标的话键盘用户点不动。 */
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) { return; }
|
||||
var zone = t.closest('[data-cmt-drop]');
|
||||
if (!zone) { return; }
|
||||
if (e.key !== 'Enter' && e.key !== ' ') { return; }
|
||||
e.preventDefault();
|
||||
var inp = zone.querySelector('[data-cmt-file-input]');
|
||||
if (inp) { inp.click(); }
|
||||
});
|
||||
|
||||
/* 点选也要更新列表(原生 onchange)—— 与拖拽共用同一个渲染函数 */
|
||||
document.addEventListener('change', function (e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.hasAttribute || !t.hasAttribute('data-cmt-file-input')) { return; }
|
||||
var zone = t.closest ? t.closest('[data-cmt-drop]') : null;
|
||||
if (!zone) { return; }
|
||||
cmtRenderNames(zone, t);
|
||||
});
|
||||
|
||||
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
|
||||
document.addEventListener('dragover', function (e) {
|
||||
var t = e.target;
|
||||
@@ -344,4 +739,5 @@
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -4521,6 +4521,116 @@ switch ($r) {
|
||||
redirect('trips/node_edit', ['id' => $nodeId]);
|
||||
break;
|
||||
|
||||
/* 🔴 #47(2026-11-05):**行程状态切换**(网页端)。
|
||||
用户:「在计划行程里面点击开始执行,它的状态自然变成进行中…
|
||||
如果它没去,取消了,那就自然变成已取消。」
|
||||
⇒ 状态由动作驱动,不再由编辑表单手工选(表单那边已删掉「执行状态」)。
|
||||
|
||||
⚠️ 返回 **JSON**(不是 redirect):前端 `app.js` 拿到后**只重绘那一段**
|
||||
的动作条,不整页刷新 —— 否则用户刚点完就被弹回顶部。
|
||||
⚠️ 返回的 `html` 是该节点动作条的新 HTML,前端用 outerHTML 就地替换。 */
|
||||
/* 🔴 #48(2026-11-05):**行程动作**(开始 / 完成 / 取消)。
|
||||
用户:「计划行程右上角…一个是开始,一个是完成,一个是编辑,一个是删除。
|
||||
在删除前面还应该有一个按钮,这个按钮是取消。」
|
||||
⇒ 返回 JSON,前端据此决定是否重载(动作会改变分组)。 */
|
||||
case 'node_act':
|
||||
$familyId = (int)Auth::familyId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if ($nodeId <= 0) {
|
||||
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
/* 校验归属(nodes → events 查一遍) */
|
||||
$stA = Database::get()->prepare(
|
||||
'SELECT n.event_id FROM trip_nodes n JOIN events e ON e.id = n.event_id
|
||||
WHERE n.id = ? AND n.family_id = ? AND e.family_id = ? AND n.deleted_at IS NULL'
|
||||
);
|
||||
$stA->execute([$nodeId, $familyId, $familyId]);
|
||||
$evId = $stA->fetchColumn();
|
||||
if ($evId === false) {
|
||||
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$resA = \App\Trip::nodeAction($familyId, (int)$evId, $nodeId, $act);
|
||||
if (empty($resA['ok'])) {
|
||||
echo json_encode(['ok' => false, 'error' => (string)($resA['error'] ?? '没成功')], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['ok' => true, 'stage' => $resA['stage'] ?? '', 'status' => $resA['status'] ?? ''], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
/* 🔴 #48:**删除**(软删,与"取消"完全不同)。
|
||||
用户:「所谓的删除,指的是我直接把这个计划给删掉了。」
|
||||
⇒ `deleted_at` 置位,彻底不显示。 */
|
||||
case 'node_del':
|
||||
$familyId = (int)Auth::familyId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if ($nodeId <= 0) {
|
||||
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$stD = Database::get()->prepare(
|
||||
'SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id
|
||||
WHERE n.id = ? AND n.family_id = ? AND e.family_id = ? AND n.deleted_at IS NULL'
|
||||
);
|
||||
$stD->execute([$nodeId, $familyId, $familyId]);
|
||||
if ($stD->fetchColumn() === false) {
|
||||
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$upD = Database::get()->prepare(
|
||||
'UPDATE trip_nodes SET deleted_at = NOW(), updated_at = NOW() WHERE id = ?'
|
||||
);
|
||||
$upD->execute([$nodeId]);
|
||||
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
case 'node_status':
|
||||
$familyId = (int)Auth::familyId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$newSt = (string)($_POST['status'] ?? '');
|
||||
if ($nodeId <= 0) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => '缺 node_id'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if (!array_key_exists($newSt, \App\Trip::NODE_STATUS)) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => '状态值不对'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
/* 校验归属(顺着 nodes → events 查一遍,防越权) */
|
||||
$stN = Database::get()->prepare(
|
||||
'SELECT n.id FROM trip_nodes n JOIN events e ON e.id = n.event_id
|
||||
WHERE n.id = ? AND n.family_id = ? AND e.family_id = ? AND n.deleted_at IS NULL'
|
||||
);
|
||||
$stN->execute([$nodeId, $familyId, $familyId]);
|
||||
if ($stN->fetchColumn() === false) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => '找不到这段行程'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$upN = Database::get()->prepare(
|
||||
'UPDATE trip_nodes SET status = ?, updated_at = NOW() WHERE id = ?'
|
||||
);
|
||||
$upN->execute([$newSt, $nodeId]);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'status' => $newSt,
|
||||
'status_word' => \App\Trip::NODE_STATUS[$newSt],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
case 'trips/expense_del':
|
||||
$familyId = (int)Auth::familyId();
|
||||
Auth::requireWrite();
|
||||
@@ -7167,6 +7277,161 @@ $want = array_keys($want);
|
||||
redirect('tasks');
|
||||
break;
|
||||
|
||||
/* 🔴 #74(用户 2026-11-05):「点开之后,就可以修改任务的所有信息,
|
||||
包括…修改截止时间。当然,如果现在已经有截止时间,就把它带进来。」
|
||||
|
||||
⇒ 首页任务面板里**直接改截止时间**,不再需要点「编辑」进那张整页表单。
|
||||
与 status / move 同一套约定:`ajax_panel=1` 就回**整块新面板**,
|
||||
否则按老规矩 flash + 302(无 JS 的兜底)。
|
||||
|
||||
⚠️ 用 `Task::setDue()` 而**不是** `Task::update()`:后者是全字段覆盖,
|
||||
只交 due_at 会把 title/note/assignee 等全部抹空(详见那个方法的注释)。
|
||||
⚠️ 字段名沿用 `due_at`(与 `_edit_form.php` 一致),
|
||||
空串 = 清掉截止时间。 */
|
||||
case 'tasks/due':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$dueAt = trim((string)($_POST['due_at'] ?? ''));
|
||||
$t = Task::find($familyId, $id, $me);
|
||||
if (!$t || !Task::canWrite($t, $me)) {
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 403);
|
||||
echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '这项任务不能改。');
|
||||
redirect('tasks');
|
||||
}
|
||||
Task::setDue($familyId, $id, $dueAt);
|
||||
$msg = $dueAt !== ''
|
||||
? '截止时间改成 ' . substr($dueAt, 0, 16) . '。'
|
||||
: '已清掉截止时间。';
|
||||
if (task_panel_wanted()) {
|
||||
task_panel_json($id, $msg);
|
||||
}
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
$back = (string)($_POST['back'] ?? '');
|
||||
if ($back === 'show') {
|
||||
redirect('tasks/show', ['id' => $id]);
|
||||
} elseif ($back === 'home') {
|
||||
redirect('home');
|
||||
}
|
||||
redirect('tasks');
|
||||
|
||||
/* 🔴 #76(用户 2026-11-05):「时间控件是对的,但是现在没有保存按钮,
|
||||
只有下面一个评论的发表按钮。另外,负责人和分类都没法修改,
|
||||
按道理讲都应该可以修改才对。」
|
||||
|
||||
⇒ 面板里「截止 / 负责人 / 分类」三项做成可编辑控件 + 一颗「保存」。
|
||||
这是用户明确要的交互:先改完,再一次性提交,而不是每动一下发一次请求
|
||||
(上一版 `tasks/due` 是"选完立即保存",用户不认可)。
|
||||
|
||||
⚠️ 用 `Task::setAttrs()`,只写这三列 —— 不是 `Task::update()`。
|
||||
后者全字段覆盖,面板没交的 title/note/status/repeat 会被抹空。
|
||||
⚠️ 三个字段都**可选**:表单里 `category_id` 没提交就不动它
|
||||
(`setAttrs` 用 `array_key_exists` 判,与 `?? null` 的"没传"区分开)。 */
|
||||
case 'tasks/panel_save':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$t = Task::find($familyId, $id, $me);
|
||||
if (!$t || !Task::canWrite($t, $me)) {
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8', true, 403);
|
||||
echo json_encode(['ok' => false, 'msg' => '这项任务不能改。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', '这项任务不能改。');
|
||||
redirect('tasks');
|
||||
}
|
||||
/* 只把**表单真的提交了**的字段放进 $sd:
|
||||
面板里负责人控件在"没有成员"等极端情况下可能不渲染,
|
||||
那时就不该把 assignee_id 当成 0 去清掉原值。 */
|
||||
$sd = [];
|
||||
if (array_key_exists('due_at', $_POST)) {
|
||||
$sd['due_at'] = trim((string)$_POST['due_at']);
|
||||
}
|
||||
if (array_key_exists('assignee_id', $_POST)) {
|
||||
$sd['assignee_id'] = (int)$_POST['assignee_id'];
|
||||
}
|
||||
if (array_key_exists('category_id', $_POST)) {
|
||||
$sd['category_id'] = (int)$_POST['category_id'];
|
||||
}
|
||||
/* #83:循环(重复规则)。与上面几个同一套 `array_key_exists` 语义 ——
|
||||
表单没带这个字段就**不动**(保留原值),带了才更新(空串 = 不循环)。 */
|
||||
if (array_key_exists('repeat_rule', $_POST)) {
|
||||
$sd['repeat_rule'] = trim((string)$_POST['repeat_rule']);
|
||||
}
|
||||
/* 🔴 #78(用户 2026-11-05):「现在在首页上点一个任务,直接就是编辑页面啊。
|
||||
所以不需要再加一个编辑按钮了。」
|
||||
⇒ 既然要删掉「编辑」按钮,标题和备注必须能在这里改。
|
||||
⚠️ 标题不允许被改成空 —— 与 tasks/update 同一口径(那里空标题会被打回)。
|
||||
空了就**不更新标题**(而不是把标题清掉)。 */
|
||||
if (array_key_exists('title', $_POST)) {
|
||||
$nt = trim((string)$_POST['title']);
|
||||
if ($nt !== '') {
|
||||
$sd['title'] = $nt;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('note', $_POST)) {
|
||||
$sd['note'] = trim((string)$_POST['note']);
|
||||
}
|
||||
Task::setAttrs($familyId, $id, $sd);
|
||||
|
||||
/* 协作人(共享给)—— #78。
|
||||
🔴 用户:「我记得任务其实可以共享给别人,也就是协作人。
|
||||
但现在怎么感觉编辑这里面没有这个选项呢?」
|
||||
⇒ 数据层早就有 `task_shares` + Task::share/unshare,只是没入口。
|
||||
|
||||
⚠️ 语义是"**以提交的列表为准**":表单没带 `shares[]` 时(比如只改截止时间
|
||||
的旧面板/其它调用方)**完全不动**协作人,避免把已有共享清空。
|
||||
表单带了(哪怕是空数组 `shares=[]` 表示"清空全部")就按它同步。 */
|
||||
if (array_key_exists('shares', $_POST) || array_key_exists('shares_submitted', $_POST)) {
|
||||
$wantIds = [];
|
||||
foreach ((array)($_POST['shares'] ?? []) as $sid) {
|
||||
$sid = (int)$sid;
|
||||
/* 不能共享给自己(空操作);也不能共享给不存在的人 */
|
||||
if ($sid > 0 && $sid !== $me) {
|
||||
$wantIds[$sid] = true;
|
||||
}
|
||||
}
|
||||
$haveIds = [];
|
||||
foreach (Task::shares($id) as $sh) {
|
||||
$haveIds[(int)$sh['person_id']] = true;
|
||||
}
|
||||
foreach (array_keys($wantIds) as $sid) {
|
||||
if (!isset($haveIds[$sid])) {
|
||||
Task::setShare($id, $sid, 'read');
|
||||
}
|
||||
}
|
||||
foreach (array_keys($haveIds) as $sid) {
|
||||
if (!isset($wantIds[$sid])) {
|
||||
Task::dropShare($id, $sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
$msg = '已保存。';
|
||||
if (task_panel_wanted()) {
|
||||
task_panel_json($id, $msg);
|
||||
}
|
||||
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
redirect('tasks/show', ['id' => $id]);
|
||||
|
||||
case 'tasks/comment_add':
|
||||
$familyId = (int)Auth::familyId();
|
||||
$me = (int)Auth::personId();
|
||||
@@ -7178,7 +7443,29 @@ $want = array_keys($want);
|
||||
flash('err', '找不到这项任务。');
|
||||
redirect('tasks');
|
||||
}
|
||||
$cid = Task::addComment($id, $me, (string)($_POST['content'] ?? ''));
|
||||
/* 🔴 #78(用户 2026-11-05):「评论那个地方也需要可以添加附件。」
|
||||
⇒ 允许「只发附件、不写字」—— 这是很常见的一次性用法
|
||||
(拍张照片甩过来,不需要配文字)。
|
||||
|
||||
⚠️ 但不能两边都空:那是误点。这时返回 0 走原来的"留言是空的"分支。
|
||||
⚠️ 判"有没有附件"要看 `$_FILES`,不能看 content:
|
||||
`attach_save_files` 内部会过滤空文件槽(未选择的 file input
|
||||
也会出现在 $_FILES 里,name 是空串)。
|
||||
这里用一个轻量判断:只要有任一非空 tmp_name 就算有附件。 */
|
||||
$rawContent = trim((string)($_POST['content'] ?? ''));
|
||||
$hasFiles = false;
|
||||
foreach ((array)($_FILES['attach'] ?? []) as $k => $v) {
|
||||
if ($k === 'name' && is_array($v)) {
|
||||
foreach ($v as $nm) {
|
||||
if (trim((string)$nm) !== '') { $hasFiles = true; break 2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rawContent === '' && $hasFiles) {
|
||||
/* 占位文案:让列表里能看懂"这条是什么"(纯空白没人知道是图还是文件) */
|
||||
$rawContent = '(附件)';
|
||||
}
|
||||
$cid = Task::addComment($id, $me, $rawContent);
|
||||
if ($cid > 0) {
|
||||
attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);
|
||||
if (task_panel_wanted()) {
|
||||
|
||||
@@ -41,6 +41,59 @@ function media_fail(int $code, string $message): void
|
||||
}
|
||||
|
||||
$key = (string)($_GET['k'] ?? '');
|
||||
|
||||
/* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
|
||||
它会给你返回一张压缩过的图片文件…如果可行的话,就用OSS的压缩就行了。」
|
||||
|
||||
`?w=400` ⇒ 让 OSS 实时返回压缩版(`x-oss-process=image/resize,l_400`)。
|
||||
|
||||
⚠️ **白名单**而不是透传:OSS 图片处理**按次计费**(约 ¥0.0005/次),
|
||||
若放任 `?w=` 任意值,`?w=1`、`?w=9999` 这种请求会让处理次数暴涨。
|
||||
只允许下面这几档 —— 覆盖实际用途,且数量可控。
|
||||
⚠️ 不带 `w` ⇒ 原图,**行为完全不变**(300+ 处既有 `<img>` 不受影响)。
|
||||
⚠️ 本地存储的图片:OSS 档位参数**自动失效**(Storage::read 里判驱动),
|
||||
用户明确要求「本地的不不管它」。 */
|
||||
$imgW = (int)($_GET['w'] ?? 0);
|
||||
/**
|
||||
* 允许的尺寸档位 ⇒ OSS 处理参数。
|
||||
*
|
||||
* 🔴 #56 修正(用户 2026-11-05):「400压缩的太多了。800也不够,
|
||||
* 只保留一个1600。」
|
||||
*
|
||||
* ⇒ 只留 1600。原来 400/800 两档删掉 —— 实测 400 只有 26KB,
|
||||
* 在手机/平板上看已经很糊;800 也不够清。
|
||||
* 用户宁可图大一点,也要看得清。
|
||||
*
|
||||
* ⚠️ 删掉这两档后,仍传 `?w=400` 的旧链接会落到"非法值 ⇒ 原图"分支,
|
||||
* **不会报错**,只是不压缩。所以旧链接是安全的,只是不再省流量。
|
||||
* ⚠️ 1600 的质量提到 **q_88**(从 85):既然只保留一档,
|
||||
* 就该在"省流量"和"看得清"之间偏向前者之外的一侧。
|
||||
*/
|
||||
$OssSizes = [
|
||||
1600 => 'image/resize,l_1600/quality,q_88',
|
||||
];
|
||||
if (!array_key_exists($imgW, $OssSizes)) {
|
||||
$imgW = 0; // 非法值一律按原图处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个 key 是图片吗(#56)。
|
||||
*
|
||||
* ⚠️ **必须在 Storage::read 之前算好** —— 压缩参数只允许给图片,
|
||||
* 给视频传 `x-oss-process=image/...` 会让 OSS 返回错误内容,
|
||||
* 表现就是"视频播不了"。(这就是 2026-11-05 那次事故的根因。)
|
||||
*
|
||||
* ⚠️ 除了路径前缀,再看扩展名兜底:`files/xxx.mp4` 不在前缀里,
|
||||
* 但万一将来有图片挪到别的目录,扩展名还能挡住视频。
|
||||
*/
|
||||
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
|
||||
if (!$isImageKey && preg_match('#\.(jpe?g|png|gif|webp|bmp|avif)$#i', $key)) {
|
||||
$isImageKey = true;
|
||||
}
|
||||
/* 视频/文档扩展名强制非图片(防止上面的扩展名兜底误判) */
|
||||
if (preg_match('#\.(mp4|mov|m4v|webm|avi|mkv|mp3|m4a|pdf|docx?|xlsx?|pptx?|rar|zip)$#i', $key)) {
|
||||
$isImageKey = false;
|
||||
}
|
||||
$stageTok = (string)($_GET['stage'] ?? '');
|
||||
|
||||
/*
|
||||
@@ -154,7 +207,6 @@ foreach (explode('/', $key) as $seg) {
|
||||
不让缓存无限长大把磁盘吃满。
|
||||
|
||||
⚠️ 顺序不能反:先放锁、再取内容(取内容可能要几百毫秒)。 */
|
||||
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
|
||||
if ($isImageKey) {
|
||||
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@@ -162,14 +214,33 @@ if ($isImageKey) {
|
||||
}
|
||||
}
|
||||
|
||||
$res = Storage::read($familyId, $key);
|
||||
/* 🔴🔴 #56 严重修复(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号…
|
||||
是你更新了这个功能之后,出了这个问题」):
|
||||
|
||||
**图片压缩参数绝不能应用到视频上。**
|
||||
|
||||
原来的写法只看 `$imgW`,不看这是不是图片:
|
||||
|
||||
$res = Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : '');
|
||||
|
||||
⇒ 视频请求带上 `?w=1600` 后,会给 **.mp4 对象**传
|
||||
`x-oss-process=image/resize,l_1600` ⇒ OSS 图片处理服务
|
||||
对非图片返回错误/空内容 ⇒ `<video>` 拿不到有效数据 ⇒ **播不了**。
|
||||
|
||||
⇒ 现与 `$isImageKey` 绑定:**只有图片才允许压缩**。
|
||||
视频/PDF/文档一律走原样。 */
|
||||
$res = Storage::read($familyId, $key,
|
||||
($imgW > 0 && $isImageKey) ? $OssSizes[$imgW] : '');
|
||||
$status = (int)($res['status'] ?? 0);
|
||||
$body = (string)($res['body'] ?? '');
|
||||
|
||||
// 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
|
||||
/* 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
|
||||
🔴 #56:**按尺寸分档存** —— 否则 400/800/1600 三档会互相覆盖,
|
||||
用户看到的尺寸随机。原图($imgW=0)仍存原路径,行为不变。 */
|
||||
if ($isImageKey && !empty($res['ok']) && $status === 200
|
||||
&& ($res['from'] ?? '') !== 'local' && $body !== '') {
|
||||
Storage::cacheLocally($key, $body);
|
||||
$cacheKey = $imgW > 0 ? ('_proc/' . $imgW . '/' . $key) : $key;
|
||||
Storage::cacheLocally($cacheKey, $body);
|
||||
}
|
||||
|
||||
if (empty($res['ok']) || $status < 200 || $status >= 300) {
|
||||
@@ -217,8 +288,25 @@ $etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
|
||||
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
|
||||
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
|
||||
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
|
||||
|
||||
/* 🔴🔴 2026-11-05 修(用户:「网页端的视频…点开之后是黑的,没办法播放」):
|
||||
**带 Range 的请求绝不能回 304**。
|
||||
|
||||
原来的顺序是"先判 304,再处理 Range"。视频第一次播完后浏览器缓存了 ETag,
|
||||
拖动进度条 / 第二次打开时会同时发:
|
||||
|
||||
Range: bytes=1048576-
|
||||
If-None-Match: "abc123"
|
||||
|
||||
⇒ 命中 304(304 **没有响应体**)⇒ `<video>` 拿不到数据 ⇒ **黑屏**。
|
||||
这就是"有时候能播、有时候黑"的根因:取决于浏览器有没有带 ETag。
|
||||
|
||||
RFC 7233 也明确:304 不能替代对 Range 的响应。
|
||||
⇒ 有 Range 就跳过 304,直接走第 6 步。 */
|
||||
$hasRange = trim((string)($_SERVER['HTTP_RANGE'] ?? '')) !== '';
|
||||
|
||||
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
|
||||
if ($inm !== '' && $inm === $etag) {
|
||||
if (!$hasRange && $inm !== '' && $inm === $etag) {
|
||||
http_response_code(304);
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: private, max-age=604800');
|
||||
@@ -261,6 +349,9 @@ if ($isRange) {
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
}
|
||||
header('Accept-Ranges: bytes');
|
||||
/* 2026-11-05:补 Last-Modified —— 部分浏览器/Safari 需要它才允许 seek,
|
||||
缺了会对某些 mp4 直接拒绝播放(表现为黑屏 + 无报错)。 */
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
|
||||
$filename = basename($key);
|
||||
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
|
||||
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
|
||||
|
||||
@@ -49,7 +49,21 @@ $attDelUrl = $attDelUrl ?? '';
|
||||
<?php
|
||||
$attKind = \App\Attachment::kind($attRow);
|
||||
$canPrev = \App\Attachment::previewable($attRow);
|
||||
$attView = media_url((string)$attRow['key']);
|
||||
/* 🔴🔴 #56(用户 2026-11-05:「你好像把 .mp4 后面也带上了问号,
|
||||
写成了 1600 这样的参数…是你更新了这个功能之后,出了这个问题」)
|
||||
|
||||
**压缩档位只能给图片。** 原来这里对**所有附件**都传 `w=1600`:
|
||||
视频 URL 变成 `media.php?k=xxx.mp4&w=1600` ⇒ OSS 收到
|
||||
`x-oss-process=image/resize,l_1600` 去处理 .mp4 ⇒ 返回错误内容
|
||||
⇒ `<video>` 拿不到有效数据 ⇒ **播不了**。
|
||||
|
||||
⇒ `$attKind === 'image'` 才传 `w`。
|
||||
|
||||
⚠️ `v`(版本号)**所有类型都传** —— 视频转码后靠它破缓存。
|
||||
⚠️ 下载链接 `$attDl` 一律不带 `w`(要原文件,不能偷偷降质)。 */
|
||||
$attOpts = $attKind === 'image' ? ['w' => 1600] : [];
|
||||
$attView = media_url((string)$attRow['key'], $attOpts,
|
||||
(int)($attRow['size'] ?? 0));
|
||||
$attDl = media_url((string)$attRow['key'], ['dl' => 1]);
|
||||
$attNm = (string)($attRow['name'] ?? '');
|
||||
$attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0);
|
||||
|
||||
@@ -187,6 +187,21 @@ $evLab = static function (string $text, string $for = '', bool $req = false) use
|
||||
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
|
||||
|
||||
<?php echo $evRowOpen(); ?>
|
||||
<?php /* 🔴 2026-11-05(用户:「全天那个应该是输入框,它应该往右和那个输入框对齐」):
|
||||
观影式排版(`.mv-row`)里每一行的左侧都有一个 62px 的 `.mv-lb` 标签列,
|
||||
控件从 62px 之后开始 —— 上面所有输入框的左边缘都在那条线上。
|
||||
而「全天」这一行**没有标签**(它不需要文字标签,checkbox 自带「全天」二字),
|
||||
`<label class="chk-inline">` 就直接贴在行的最左边(x=0),
|
||||
比上面的输入框往左偏了 62px,看着像"错位了"。
|
||||
|
||||
⇒ 补一个**空的** `.mv-lb` 占位(`no-colon` 免得画出「:」)。
|
||||
不用 `padding-left:62px` 的原因:那条 62px 是 `.mv-row > .mv-lb`
|
||||
的 `flex:0 0 62px` 算出来的,而 `.mv-lb` 的宽度在窄屏媒体查询里
|
||||
会变(见 app.css 的 6196 行附近),写死 padding 会跟它脱节。
|
||||
用空标签则**天然跟随**同一套规则,改一处两边都对。
|
||||
⚠️ 仅在观影式排版(`$evMv`)下补:整页表单是 `.form-grid` 两列,
|
||||
没有 62px 标签列,补了反而会多出缩进。 */ ?>
|
||||
<?php if ($evMv): ?><span class="mv-lb no-colon" aria-hidden="true"></span><?php endif; ?>
|
||||
<label class="chk-inline">
|
||||
<input type="checkbox" id="ev-allday" name="all_day" value="1"
|
||||
<?= $evIsAllDay ? 'checked' : '' ?>>
|
||||
|
||||
@@ -56,6 +56,24 @@
|
||||
'title' => (string)$t['title'],
|
||||
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
||||
'note' => (string)($t['note'] ?? ''),
|
||||
/* 🔴🔴 2026-11-05(用户:「网页端,在首页上修改一个日程,
|
||||
没有填写截止日期的地方」):
|
||||
|
||||
根因:`fb_entry_json()` 用 `kind === 'custom' && ref_id > 0`
|
||||
算 `$evOwn`,而这里**只传了 edit 地址、没传 ref_id**
|
||||
⇒ `$evOwn = false` ⇒ `source = 'derived'`
|
||||
⇒ 浮层底栏的「编辑」「删除」两颗按钮**都不显示**
|
||||
⇒ 用户点开任务只能看到只读面板,压根没有进编辑表单的入口
|
||||
⇒ 永远看不到「截止时间」(那控件只在 _edit_form.php 里)。
|
||||
|
||||
⚠️ 只补 `ref_id`,**不补 kind**:`fb_entry_json` 的判据是
|
||||
`kind === 'custom'`,而这个值只用来区分
|
||||
「日历里真正属于某本日历的条目」vs「从别的模块派生的」。
|
||||
对任务来说它只关心"能不能在这里改",答案是**能**
|
||||
(有 `tasks/edit`),所以让它走默认的 'custom' 就对了。
|
||||
⚠️ 补了之后 `del` 也会变非空 —— 这是**正确的**,
|
||||
任务本来就能删,只是之前被同一个 bug 一起藏掉了。 */
|
||||
'ref_id' => (int)$t['id'],
|
||||
/* v78:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */
|
||||
'panel' => url('tasks/panel', ['id' => (int)$t['id']]),
|
||||
], url('tasks/show', ['id' => (int)$t['id']]),
|
||||
|
||||
+10
-1
@@ -134,9 +134,18 @@ if ($_needAddr): ?>
|
||||
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
|
||||
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
|
||||
模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?>
|
||||
<?php
|
||||
/* #85:下拉框浮层要用的三档浅色(与 FeedLite 同一套比例)。
|
||||
在这里一次算好,CSS 直接用 var(--brand-tint-1) 之类。
|
||||
⚠️ 算不出来(主题色不是 6 位 hex)就留空,CSS 里有 fallback。 */
|
||||
$fbTint1 = fb_color_tint((string)$themeVars['brand'], 0.91);
|
||||
$fbTint2 = fb_color_tint((string)$themeVars['brand'], 0.80);
|
||||
$fbTint3 = fb_color_tint((string)$themeVars['brand'], 0.72);
|
||||
?>
|
||||
<body class="mod-<?= e($moduleKey) ?>"
|
||||
data-media-base="media.php?k="
|
||||
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;">
|
||||
style="--brand: <?= e($themeVars['brand']) ?>; --brand-2: <?= e($themeVars['brand2']) ?>; --brand-ink: <?= e($themeVars['ink']) ?>;
|
||||
--brand-tint-1: <?= e($fbTint1) ?>; --brand-tint-2: <?= e($fbTint2) ?>; --brand-tint-3: <?= e($fbTint3) ?>;">
|
||||
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<header class="topbar">
|
||||
|
||||
+209
-23
@@ -68,6 +68,12 @@ foreach ($tpCats as $tpCat) {
|
||||
}
|
||||
}
|
||||
$tpDue = (string)($tpT['due_at'] ?? '');
|
||||
/* #78:协作人(共享给)。Task::find 已经带回 `shares`,直接用。 */
|
||||
$tpShares = (array)($tpT['shares'] ?? []);
|
||||
$tpShareIds = [];
|
||||
foreach ($tpShares as $tpS) {
|
||||
$tpShareIds[] = (int)($tpS['person_id'] ?? 0);
|
||||
}
|
||||
$tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null);
|
||||
$tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue);
|
||||
?>
|
||||
@@ -75,11 +81,31 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
||||
data-tk-url-status="<?= e(url('tasks/status')) ?>"
|
||||
data-tk-url-move="<?= e(url('tasks/move')) ?>"
|
||||
data-tk-url-cmt-del="<?= e(url('tasks/comment_del')) ?>"
|
||||
data-tk-url-attach="<?= e(url('tasks/attach_add')) ?>">
|
||||
data-tk-url-attach="<?= e(url('tasks/attach_add')) ?>"
|
||||
<?php /* #74:面板里直接改截止时间(不必点「编辑」进整页表单) */ ?>
|
||||
data-tk-url-due="<?= e(url('tasks/due')) ?>"
|
||||
<?php /* #76:截止/负责人/分类 三项一起保存(用户要显式「保存」按钮) */ ?>
|
||||
data-tk-url-save="<?= e(url('tasks/panel_save')) ?>">
|
||||
<?php /* CSRF 放在面板里(见头部第 ① 条):表单各自带一份,JS 里的按钮动作
|
||||
从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?>
|
||||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>" data-tk-csrf>
|
||||
|
||||
<?php /* ---- 任务标题 ---- */ ?>
|
||||
<div class="tk-sec">
|
||||
<div class="tk-attrs">
|
||||
<div class="tk-attr-row">
|
||||
<span class="tk-attr-lb">任务标题</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<input class="input" id="tkp-title" name="title" maxlength="200"
|
||||
value="<?= e((string)($tpT['title'] ?? '')) ?>">
|
||||
<?php else: ?>
|
||||
<span class="tk-attr-val"><?= e((string)($tpT['title'] ?? '')) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?>
|
||||
<div class="tk-sec tk-sec-inline">
|
||||
<span class="tk-sec-lb">状态</span>
|
||||
@@ -123,28 +149,148 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ---- 只读的事实(有才显示) ---- */ ?>
|
||||
<dl class="kv tk-kv">
|
||||
<?php if ($tpDue !== ''): ?>
|
||||
<div class="kv-row"><dt>截止</dt>
|
||||
<dd class="<?= $tpLate ? 'kb-late' : '' ?>"><?= e($tpDue) ?><?= $tpDueWord !== '' ? '(' . e($tpDueWord) . ')' : '' ?></dd>
|
||||
<?php /* ---- 更多信息:截止 / 负责人 / 备注 / 协作人 ----
|
||||
🔴🔴 #82(用户 2026-11-05):「后续内容也和鸿蒙版保持一致,不需要额外信息。
|
||||
布局顺序也和鸿蒙版一致:1.任务标题 2.状态 3.看板
|
||||
4.截止日期、负责人、备注、协作人 5.子任务」
|
||||
|
||||
⇒ 去掉「分类」和「循环」两条 —— 它们属于用户说的"额外信息"
|
||||
(分类是标签、循环是只读事实,鸿蒙端面板里也没有)。
|
||||
⇒ 顺序改成 截止 → 负责人 → 备注 → 协作人(与鸿蒙端逐项对齐)。
|
||||
⚠️ 服务端 `tasks/panel_save` 收不到 category_id 时会**保留原值**
|
||||
(`array_key_exists` 判"传没传"),所以删掉这个字段不会清空分类。 */ ?>
|
||||
<div class="tk-sec">
|
||||
<div class="tk-attrs">
|
||||
|
||||
<div class="tk-attr-row">
|
||||
<span class="tk-attr-lb">截止</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<?php
|
||||
/* ⚠️ 变量名必须与 `_datetime.php` 的约定一致;
|
||||
它内部一律用 `dt` 前缀的临时变量,不会踩到这里的 $tp* 或宿主页的 $t。 */
|
||||
$dtName = 'due_at';
|
||||
$dtValue = $tpDue;
|
||||
$dtPlugin = 'antd-single';
|
||||
$dtRequired = false;
|
||||
$dtId = 'tkp-due';
|
||||
require __DIR__ . '/../_datetime.php';
|
||||
?>
|
||||
<?php if ($tpDueWord !== ''): ?>
|
||||
<span class="tk-due-word<?= $tpLate ? ' kb-late' : '' ?>"><?= e($tpDueWord) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="<?= $tpLate ? 'kb-late' : '' ?>"><?= $tpDue !== '' ? e($tpDue) : '未设' ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($tpT['assignee_name'])): ?>
|
||||
<div class="kv-row"><dt>负责人</dt>
|
||||
<dd><?= e(person_name(['name' => $tpT['assignee_name'], 'my_nickname' => $tpT['assignee_nick'] ?? ''])) ?></dd>
|
||||
|
||||
<div class="tk-attr-row">
|
||||
<span class="tk-attr-lb">负责人</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<?php
|
||||
$ppName = 'assignee_id';
|
||||
$ppPeople = $tpPeople;
|
||||
$ppSelected = [(int)($tpT['assignee_id'] ?? 0)];
|
||||
$ppMode = 'single';
|
||||
$ppOpenBtn = '更换';
|
||||
$ppTitle = '谁负责这件事';
|
||||
$ppEmptyText = '不指定';
|
||||
$ppAllowEmpty = true;
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
<?php else: ?>
|
||||
<span><?= !empty($tpT['assignee_name'])
|
||||
? e(person_name(['name' => $tpT['assignee_name'],
|
||||
'my_nickname' => $tpT['assignee_nick'] ?? '']))
|
||||
: '不指定' ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($tpCatName !== ''): ?>
|
||||
<div class="kv-row"><dt>分类</dt><dd><?= e($tpCatName) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($tpT['repeat_rule'])): ?>
|
||||
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord((string)$tpT['repeat_rule'])) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
<?php if (trim((string)($tpT['note'] ?? '')) !== ''): ?>
|
||||
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$tpT['note'])) ?></dd></div>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
|
||||
<div class="tk-attr-row tk-attr-row-top">
|
||||
<span class="tk-attr-lb">备注</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<textarea class="input" id="tkp-note" name="note" rows="2"
|
||||
placeholder="补充说明…"><?= e((string)($tpT['note'] ?? '')) ?></textarea>
|
||||
<?php else: ?>
|
||||
<span><?= trim((string)($tpT['note'] ?? '')) !== ''
|
||||
? nl2br(e((string)$tpT['note'])) : '无' ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tk-attr-row tk-attr-row-top">
|
||||
<span class="tk-attr-lb">协作人</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<?php
|
||||
$ppName = 'shares[]';
|
||||
$ppPeople = array_values(array_filter($tpPeople,
|
||||
fn($pp) => (int)($pp['id'] ?? 0) !== $tpMe));
|
||||
$ppSelected = array_values(array_filter($tpShareIds,
|
||||
fn($sid) => $sid !== $tpMe));
|
||||
$ppMode = 'multi';
|
||||
$ppOpenBtn = '+ 添加人';
|
||||
$ppTitle = '共享给谁';
|
||||
require __DIR__ . '/../_person_picker.php';
|
||||
?>
|
||||
<?php else: ?>
|
||||
<?php if (!$tpShares): ?>
|
||||
<span>无</span>
|
||||
<?php else: ?>
|
||||
<span class="tk-share-chips">
|
||||
<?php foreach ($tpShares as $tpS): ?>
|
||||
<span class="tk-share-chip"><?= e(person_name($tpS)) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* 🔴 #83(用户 2026-11-05):「你倒是提醒我了,分类和重复,也就是循环,
|
||||
都需要加上,鸿蒙端也得加上。」
|
||||
|
||||
#82 里我按「不需要额外信息」把这两项删了 —— 那是我理解偏了,
|
||||
用户说的是"不要编辑表单里那些额外的字段",不是"分类/循环不要"。
|
||||
⇒ 补回。位置放在协作人之后:用户明确指定的前四项
|
||||
(截止/负责人/备注/协作人)顺序不动,这两项作为补充排在后面。 */ ?>
|
||||
<div class="tk-attr-row">
|
||||
<span class="tk-attr-lb">分类</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<select class="select" name="category_id" id="tkp-category">
|
||||
<option value="0">未分类</option>
|
||||
<?php foreach ($tpCats as $tpC): ?>
|
||||
<?php $tpCid = (int)($tpC['id'] ?? 0); ?>
|
||||
<option value="<?= $tpCid ?>"
|
||||
<?= $tpCid === (int)($tpT['category_id'] ?? 0) ? ' selected' : '' ?>>
|
||||
<?= e((string)($tpC['name'] ?? '')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php else: ?>
|
||||
<span class="tk-attr-val"><?= $tpCatName !== '' ? e($tpCatName) : '未分类' ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tk-attr-row">
|
||||
<span class="tk-attr-lb">循环</span>
|
||||
<?php if ($tpCan): ?>
|
||||
<select class="select" name="repeat_rule" id="tkp-repeat">
|
||||
<?php foreach (\App\Task::REPEAT as $tpRk => $tpRv): ?>
|
||||
<option value="<?= e($tpRk) ?>"
|
||||
<?= (string)($tpT['repeat_rule'] ?? '') === (string)$tpRk ? ' selected' : '' ?>>
|
||||
<?= e($tpRv) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php else: ?>
|
||||
<span class="tk-attr-val"><?= e(\App\Task::repeatWord((string)($tpT['repeat_rule'] ?? ''))) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($tpCan): ?>
|
||||
<div class="tk-attr-actions">
|
||||
<button type="button" class="btn btn-sm" data-tk-act="save-attrs">保存</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 子任务(子任务自己不再挂子任务,与详情页同一口径) ---- */ ?>
|
||||
<?php if (!$tpIsChild): ?>
|
||||
@@ -259,12 +405,52 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
||||
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($tpCan): ?>
|
||||
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form">
|
||||
<form method="post" action="<?= e(url('tasks/comment_add')) ?>" class="cmt-form"
|
||||
enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= $tpId ?>">
|
||||
<input type="hidden" name="ajax_panel" value="1">
|
||||
<textarea class="input" name="content" rows="2" required
|
||||
<textarea class="input" name="content" rows="2"
|
||||
placeholder="说点什么… 写 @名字 可以点名"></textarea>
|
||||
<?php /* 🔴 #78(用户 2026-11-05):「评论那个地方也需要可以添加附件。」
|
||||
|
||||
服务端**早就支持**了 —— `tasks/comment_add` 里一直有
|
||||
`attach_save_files($familyId, 'task_comment', $cid, 'attach', $me);`
|
||||
(index.php 那一行),只是这个表单里**没有放 file input**。
|
||||
⇒ 补上。字段名 `attach[]` 与详情页、任务附件同一套。
|
||||
|
||||
⚠️ `<form>` 必须带 `enctype="multipart/form-data"`,
|
||||
否则浏览器只提交文件名、文件本体丢失(那种"上传成功但打开是空的"最迷惑)。
|
||||
⚠️ 纯附件不写字也允许(把 `required` 从 textarea 上去掉了)——
|
||||
"只发一张图"是常见用法;服务端在 content 为空时会用一句
|
||||
占位文案,见 comment_add 的处理。
|
||||
⚠️ 用原生 `<input type=file>` 而不是拖拽框:评论是一次性提交,
|
||||
拖拽框那套(`data-tk-drop-box`)是"点了立刻上传",两者语义不同。 */ ?>
|
||||
<?php /* 🔴 #79(用户 2026-11-05):「评论框下面的附件也需要可以拖动添加。」
|
||||
🔴 #81(同日):「评论里面拖文件上传附件,那个地方太小了。」
|
||||
|
||||
⇒ 原来是一个 `padding:4px 10px` 的小胶囊 + 一行小字提示,
|
||||
拖动落点只有 ~26px 高,触屏上根本不好瞄,桌面上也经常拖偏。
|
||||
⇒ 改成与**「任务附件」那块完全一样**的 `.upbox`:
|
||||
高 96px、虚线大框、加号图标 + 说明文字。
|
||||
同一个交互在一个页面里不该长两个样子,也不该一个大一个小。
|
||||
|
||||
⚠️ 这块的语义是"挂到评论表单、点发表才一起提交",
|
||||
而上面「任务附件」是"拖上去立刻上传" —— 外观一致但行为不同,
|
||||
所以说明文字要写清"随这条评论一起发出",避免用户以为已经传上去了。
|
||||
⚠️ `data-cmt-drop` 仍是拖拽落点标记(task_panel.js 认它),
|
||||
不要改成 `data-tk-drop-box`(那是立刻上传那条路)。 */ ?>
|
||||
<div class="cmt-attach-row" data-cmt-drop>
|
||||
<label class="upbox cmt-upbox" role="button" tabindex="0"
|
||||
aria-label="点击选择文件,或把文件拖进来">
|
||||
<input type="file" name="attach[]" multiple class="sr-only"
|
||||
id="tkp-cmt-files" data-cmt-file-input>
|
||||
<span class="upbox-ico" aria-hidden="true">+</span>
|
||||
<span class="upbox-msg">点击选择,或把文件拖到这里</span>
|
||||
<span class="cmt-upbox-sub">点「发表」时随这条评论一起发出</span>
|
||||
</label>
|
||||
<div class="cmt-file-list" data-cmt-file-name hidden></div>
|
||||
</div>
|
||||
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -171,13 +171,12 @@ $nfEmbed = !empty($nfEmbed);
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<span class="tip">在右上角「设置 → 节点类型」里可以增删自定义。</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>当前状态</label>
|
||||
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($stage)) ?></div>
|
||||
</div>
|
||||
<?php /* 🔴 #47(2026-11-05):**「当前状态」不再显示**。
|
||||
用户:「实际行程、当前状态实际行程,这个地方也不要出现,
|
||||
因为这是没有意义的。我现在本来就是添加的一个实际行程。」
|
||||
⇒ 阶段由入口决定(计划/实际两颗按钮),编辑时不该再展示。 */ ?>
|
||||
|
||||
<?php /* ---- 标题 ---- */ ?>
|
||||
<div class="field full">
|
||||
@@ -202,7 +201,6 @@ $nfEmbed = !empty($nfEmbed);
|
||||
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
|
||||
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
|
||||
require __DIR__ . '/../_datetime.php'; ?>
|
||||
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
|
||||
</div>
|
||||
|
||||
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
|
||||
@@ -251,7 +249,6 @@ $nfEmbed = !empty($nfEmbed);
|
||||
<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 /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
|
||||
原话:「添加行程,线路名、承运方,都不要了。」
|
||||
@@ -283,16 +280,15 @@ $nfEmbed = !empty($nfEmbed);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="nf-status">执行状态</label>
|
||||
<select class="select" id="nf-status" name="status">
|
||||
<?php foreach (\App\Trip::NODE_STATUS as $sk => $sv): ?>
|
||||
<option value="<?= e($sk) ?>"<?= ($n['status'] ?? 'planned') === $sk ? ' selected' : '' ?>>
|
||||
<?= e($sv) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php /* 🔴 #47(2026-11-05):**「执行状态」不再由表单选**。
|
||||
用户:「这个里面不要让它手动去动。只要是添加的计划行程,
|
||||
它的状态就是计划中。在计划行程里面点击开始执行,
|
||||
它的状态自然变成进行中…所以编辑这个地方不要出现
|
||||
计划中、进行中、已完成、已取消。」
|
||||
|
||||
⇒ 状态由 show.php 节点卡片上的动作按钮改(#47 新增的 API)。
|
||||
表单**不提交 status**,服务端 saveNode 的 PATCH 语义会沿用原值
|
||||
(见 Trip::saveNode 里 `array_key_exists` 的判定)。 */ ?>
|
||||
|
||||
<div class="field full">
|
||||
<label for="nf-detail">记录</label>
|
||||
|
||||
+86
-13
@@ -36,10 +36,14 @@ foreach ((array)($nodes ?? []) as $n) {
|
||||
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
|
||||
$bysStage[$sg][] = $n;
|
||||
}
|
||||
/* 🔴 #48(2026-11-05):**提示语删掉**(用户:「已走过 —— 也出现在日历与首页
|
||||
『往年今日』,这句话删除」)。不只删这一句 —— 三个分组的提示语一律去掉:
|
||||
组头只留名称和条数,界面更干净。
|
||||
⚠️ 保留数组结构(只用 [0] 名称),改动面最小。 */
|
||||
$groupMeta = [
|
||||
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
|
||||
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
|
||||
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
|
||||
'plan' => ['计划的行程'],
|
||||
'actual' => ['实际行程'],
|
||||
'skipped' => ['未成行'],
|
||||
];
|
||||
?>
|
||||
<div class="page-head">
|
||||
@@ -97,7 +101,8 @@ $groupMeta = [
|
||||
<div class="card card-pad trip-hero">
|
||||
<div class="trip-hero-cover">
|
||||
<?php if (!empty($t['cover_key'])): ?>
|
||||
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt="">
|
||||
<?php /* #56:题图是大图位置,给 1600 档(实测 6.6MB → 468KB) */ ?>
|
||||
<img src="<?= e(media_url((string)$t['cover_key'], ['w' => 1600])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span class="trip-cover-empty">🧳</span>
|
||||
<?php endif; ?>
|
||||
@@ -228,14 +233,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach (['plan', 'actual', 'skipped'] as $sg): ?>
|
||||
<?php /* 🔴 #48:**实际行程放最前** —— 用户:「到最后整个旅程结束之后,
|
||||
我们最终看到的是实际行程。」⇒ actual → plan → skipped(未成行沉底)。 */ ?>
|
||||
<?php foreach (['actual', 'plan', 'skipped'] as $sg): ?>
|
||||
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
|
||||
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
|
||||
<h2 class="cinema-h2">
|
||||
<?= e($groupMeta[$sg][0]) ?>
|
||||
<span class="group-n"><?= count($list) ?> 段</span>
|
||||
</h2>
|
||||
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
|
||||
|
||||
<ol class="node-list">
|
||||
<?php foreach ($list as $n): ?>
|
||||
@@ -250,8 +256,12 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
?>
|
||||
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
|
||||
id="node-<?= $nid ?>">
|
||||
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>">
|
||||
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?>
|
||||
<?php /* 🔴 2026-11-05(用户:「明明选的是坐火车,网页版的图标显示的还是一辆汽车」):
|
||||
左侧图标改成**出行方式优先** —— 坐火车就显 🚄,
|
||||
不再一律显节点类型的 🚗。与端上 `fbNodeLeadIcon()` 同一逻辑。 */ ?>
|
||||
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)
|
||||
. ($mode !== '' ? ' · ' . \App\Trip::modeWord($mode) : '')) ?>">
|
||||
<?= e(\App\Trip::leadIcon((string)$n['node_type'], $mode, $fid)) ?>
|
||||
</span>
|
||||
<div class="node-body">
|
||||
<div class="node-head">
|
||||
@@ -285,8 +295,22 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
|
||||
<?php if ($span !== '日期待补'): ?>
|
||||
<div class="node-time">
|
||||
<?= e($span) ?>
|
||||
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
|
||||
<?php /* 🔴 #48:**计划时间与实际时间同时显示**。
|
||||
用户:「原来计划的时间和现在真正开始的时间都要记录。」
|
||||
⇒ 计划用常规色,实际用主题色并标注「实际」。 */ ?>
|
||||
<?php if ($sg === 'actual'): ?>
|
||||
<span class="t-plan">计划 <?= e($span) ?></span>
|
||||
<?php
|
||||
$aStart = (string)($n['actual_started_at'] ?? '');
|
||||
$aEnd = (string)($n['actual_ended_at'] ?? '');
|
||||
if ($aStart !== ''):
|
||||
$aSpan = \App\Trip::nodeSpan($aStart, $aEnd !== '' ? $aEnd : null);
|
||||
?>
|
||||
<span class="t-actual">实际 <?= e($aSpan) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?= e($span) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -316,23 +340,72 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
||||
$navFrom = \App\Geo::navUrl($n['from_lng'] ?? null, $n['from_lat'] ?? null, (string)($n['from_place'] ?? ''));
|
||||
$navTo = \App\Geo::navUrl($n['to_lng'] ?? null, $n['to_lat'] ?? null, (string)($n['to_place'] ?? ''));
|
||||
?>
|
||||
<?php /* 🔴 2026-11-05(用户:「直接用个标签,然后右边一个箭头,
|
||||
到北京南站就得了,不用写从哪到哪」):
|
||||
两个地名都做成**胶囊标签**,箭头只作分隔。
|
||||
有坐标的套 <a>(可点开导航),没坐标的套 span ——
|
||||
**两种情况都用同一个类**,样式才一致(原来无链接时
|
||||
是裸文本,和另一侧的胶囊对不齐,这正是"位都没对齐"的观感来源)。 */ ?>
|
||||
<?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'] ?? '—')) ?>
|
||||
<span class="route-stop"><?= e((string)($n['from_place'] ?? '—')) ?></span>
|
||||
<?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'] ?? '—')) ?>
|
||||
<span class="route-stop"><?= e((string)($n['to_place'] ?? '—')) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($n['detail'])): ?>
|
||||
<!-- 🔴🔴 #48(2026-11-05)行程动作按钮组。
|
||||
用户原话:
|
||||
「计划行程右上角就不是两个按钮了,而是至少四个按钮:
|
||||
一个是"开始",一个是"完成",一个是"编辑",一个是"删除"。
|
||||
在删除前面还应该有一个按钮,这个按钮是取消,
|
||||
指的就是不去了,但这个事情我并不把它删掉。」
|
||||
|
||||
⇒ 计划行程:开始 / 完成 / 编辑 / 取消 / 删除
|
||||
⇒ 实际行程:编辑 / 删除(无状态、无开始无完成)
|
||||
⇒ 未成行: 编辑 / 删除(已取消,不再转)
|
||||
|
||||
⚠️ 当前状态用实心 chip 表示,与端上 pillBtn(selected) 同一视觉语言。
|
||||
⚠️ 按钮规则来自 Trip::nodeAvailableActions() —— 前后端共用一份。
|
||||
|
||||
⚠️ 这里用 HTML 注释而不是 PHP 注释:PHP 的 /* */ 注释里遇到 `?>`
|
||||
会提前结束 PHP 段(词法层面 `?>` 优先于注释),导致解析错误。 -->
|
||||
<?php
|
||||
$stageNow = (string)($n['stage'] ?? 'plan');
|
||||
$statusNow = (string)($n['status'] ?? 'planned');
|
||||
$acts = \App\Trip::nodeAvailableActions($stageNow, $statusNow);
|
||||
$isActual = $stageNow === 'actual';
|
||||
?>
|
||||
<div class="node-actions">
|
||||
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
|
||||
<span class="node-act is-on"><?= e(\App\Trip::NODE_STATUS[$statusNow] ?? '计划中') ?></span>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($acts as $a): ?>
|
||||
<?php
|
||||
$label = $a === 'start' ? '开始' : '完成';
|
||||
$cls = $a === 'start' ? 'go' : 'ok';
|
||||
?>
|
||||
<button type="button" class="node-act node-act-<?= e($cls) ?>"
|
||||
data-node-act="<?= e($a) ?>"
|
||||
data-node="<?= e($nid) ?>"><?= e($label) ?></button>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$isActual && $stageNow !== 'skipped'): ?>
|
||||
<button type="button" class="node-act node-act-warn"
|
||||
data-node-act="cancel"
|
||||
data-node="<?= e($nid) ?>"
|
||||
title="不去了,但数据保留">取消</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($n['detail'])): ?>
|
||||
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user