Compare commits
54
Commits
ff818a3deb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835f2846f2 | ||
|
|
94a557cd2e | ||
|
|
c6e4191bb3 | ||
|
|
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 | ||
|
|
f92a98a3a3 | ||
|
|
610d524e68 | ||
|
|
c86658966c | ||
|
|
821cfd1542 | ||
|
|
ce752073db | ||
|
|
0b8012f5dd | ||
|
|
7f3df7a40b | ||
|
|
01d5bc262f | ||
|
|
73c84c1910 | ||
|
|
29b2ca0e3c | ||
|
|
df657e5e5a | ||
|
|
42aff02687 | ||
|
|
b6dc60452a | ||
|
|
ad71c80f48 | ||
|
|
47247e39b9 | ||
|
|
e44f37e902 | ||
|
|
cd56a9d8fe | ||
|
|
7c86859ab9 | ||
|
|
4941e9db9c | ||
|
|
ea6e42c27f | ||
|
|
e733c4f057 | ||
|
|
fc81347e8d |
+77
-1
@@ -282,6 +282,9 @@ final class Anniversary
|
|||||||
'is_leap' => 0,
|
'is_leap' => 0,
|
||||||
'person_id' => $pid,
|
'person_id' => $pid,
|
||||||
'person_name' => (string)($p['name'] ?? ''),
|
'person_name' => (string)($p['name'] ?? ''),
|
||||||
|
/* #87:头像给端上画"生日只读卡"用(key,端上拼 URL) */
|
||||||
|
'avatar' => (string)($p['avatar'] ?? ''),
|
||||||
|
'avatar_color' => (string)($p['avatar_color'] ?? ''),
|
||||||
'source' => 'birthday_solar',
|
'source' => 'birthday_solar',
|
||||||
'born' => $solar,
|
'born' => $solar,
|
||||||
];
|
];
|
||||||
@@ -299,6 +302,8 @@ final class Anniversary
|
|||||||
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
|
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
|
||||||
'person_id' => $pid,
|
'person_id' => $pid,
|
||||||
'person_name' => (string)($p['name'] ?? ''),
|
'person_name' => (string)($p['name'] ?? ''),
|
||||||
|
'avatar' => (string)($p['avatar'] ?? ''),
|
||||||
|
'avatar_color' => (string)($p['avatar_color'] ?? ''),
|
||||||
'source' => 'birthday_lunar',
|
'source' => 'birthday_lunar',
|
||||||
'born' => $lunar,
|
'born' => $lunar,
|
||||||
];
|
];
|
||||||
@@ -586,7 +591,24 @@ final class Anniversary
|
|||||||
public static function occurrences(int $familyId, int $viewerId, int $year): array
|
public static function occurrences(int $familyId, int $viewerId, int $year): array
|
||||||
{
|
{
|
||||||
$out = [];
|
$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');
|
$repeat = (string)($a['repeat_type'] ?? 'solar');
|
||||||
$m = (int)($a['month'] ?? 0);
|
$m = (int)($a['month'] ?? 0);
|
||||||
$d = (int)($a['day'] ?? 0);
|
$d = (int)($a['day'] ?? 0);
|
||||||
@@ -644,6 +666,60 @@ final class Anniversary
|
|||||||
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
|
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
|
||||||
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
|
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
|
||||||
'person_id' => (int)($a['person_id'] ?? 0),
|
'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'),
|
'source' => (string)($a['source'] ?? 'custom'),
|
||||||
'note' => (string)($a['note'] ?? ''),
|
'note' => (string)($a['note'] ?? ''),
|
||||||
'id' => (int)($a['id'] ?? 0),
|
'id' => (int)($a['id'] ?? 0),
|
||||||
|
|||||||
+865
-40
File diff suppressed because it is too large
Load Diff
+43
-1
@@ -83,8 +83,45 @@ final class Attachment
|
|||||||
}
|
}
|
||||||
$key = (string)$up['file'];
|
$key = (string)$up['file'];
|
||||||
} else {
|
} 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) : '');
|
$key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : '');
|
||||||
$res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream');
|
$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'])) {
|
if (empty($res['ok'])) {
|
||||||
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
|
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
|
||||||
}
|
}
|
||||||
@@ -104,7 +141,12 @@ final class Attachment
|
|||||||
self::nextPosition($type, $targetId),
|
self::nextPosition($type, $targetId),
|
||||||
$uploaderId > 0 ? $uploaderId : null,
|
$uploaderId > 0 ? $uploaderId : null,
|
||||||
]);
|
]);
|
||||||
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'id' => (int)$db->lastInsertId(),
|
||||||
|
/* 转码说明(空串 = 没转,不需要提示) */
|
||||||
|
'note' => $transcodeNote ?? '',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删一个附件(连存储对象一起) */
|
/** 删一个附件(连存储对象一起) */
|
||||||
|
|||||||
+2
-2
@@ -195,12 +195,12 @@ final class Auth
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
||||||
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。
|
* 登录后看到的不是品牌名「岁月家」,而是「<我的名字>的家庭岁月」。
|
||||||
*/
|
*/
|
||||||
public static function homeTitle(): string
|
public static function homeTitle(): string
|
||||||
{
|
{
|
||||||
$name = self::name();
|
$name = self::name();
|
||||||
return $name === '' ? '家书' : $name . '的家书';
|
return $name === '' ? '岁月家' : $name . '的家庭岁月';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未登录跳登录页 */
|
/** 未登录跳登录页 */
|
||||||
|
|||||||
+68
-13
@@ -4,12 +4,12 @@
|
|||||||
*
|
*
|
||||||
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
|
* 三条核心设计(用户 2026-10-01 第 7 条逐条对应):
|
||||||
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
|
* ① **每人可建日历本**(自定义名字 + 自定义颜色),并能共享给别人(只读 / 可写)。
|
||||||
* ② **观影与旅行是每人默认的两本**,名字就叫「观影」「旅行」,内容**从 events 派生** ——
|
* ② **观影与旅程是每人默认的两本**,名字就叫「观影」「旅程」,内容**从 events 派生** ——
|
||||||
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
|
* 不复制一份进 calendar_events,否则"改了观影记录、日历还是旧的"。
|
||||||
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
|
* 反过来,在日历里给观影本新增日程 = 写一条 events(kind='movie')(真的成为一条观影记录)。
|
||||||
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
|
* ③ **没有具体时分的日程 = 全天日程**(all_day=1)。
|
||||||
*
|
*
|
||||||
* 日历其实是"综合视图":自定义日程 + 观影 + 旅行 + 有截止时间的任务,
|
* 日历其实是"综合视图":自定义日程 + 观影 + 旅程 + 有截止时间的任务,
|
||||||
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
|
* 各模块各自出数据,日历只负责按日期摆出来(见 Calendar::month())。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -49,7 +49,15 @@ final class Calendar
|
|||||||
custom 排最前,所以它天然就是"新建日程"的默认本。 */
|
custom 排最前,所以它天然就是"新建日程"的默认本。 */
|
||||||
'custom' => ['我的日程', '#4A7C82'],
|
'custom' => ['我的日程', '#4A7C82'],
|
||||||
'movie' => ['观影', '#7A4E8C'],
|
'movie' => ['观影', '#7A4E8C'],
|
||||||
'trip' => ['旅行', '#3F7D5A'],
|
/* 🔴 #71(用户 2026-11-05):「旅程的日历本同步到日历本,它的日历本名字
|
||||||
|
就叫"旅程",观影就叫"观影"。我看现在"旅程"叫"旅行",这个不对,应该改过来。」
|
||||||
|
|
||||||
|
⇒ 「旅行」→「旅程」。模块本身一直叫"旅程"(页面/菜单/接口全是旅程),
|
||||||
|
只有这本默认日历的名字是"旅行" —— 用户进日历看到"旅行"会觉得是另一个东西。
|
||||||
|
⚠️ 改这里只影响**新家庭首次 seed**;已有家庭的库里数据要单独 UPDATE
|
||||||
|
(见同批部署的 SQL)。
|
||||||
|
⚠️ `movie` 那条**不要动** —— 用户明确说"观影就叫观影",本来就是对的。 */
|
||||||
|
'trip' => ['旅程', '#3F7D5A'],
|
||||||
'lunar' => ['农历', '#8C6D3F'],
|
'lunar' => ['农历', '#8C6D3F'],
|
||||||
'holiday' => ['节假日', '#B0342C'],
|
'holiday' => ['节假日', '#B0342C'],
|
||||||
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
|
// v24 第 12 批 #4(用户原话:「增加系统日历本,一个是传统节日…再增加一个
|
||||||
@@ -1526,9 +1534,29 @@ final class Calendar
|
|||||||
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
|
$vetm = $vd['ends_at'] ? substr((string)$vd['ends_at'], 11, 5) : '';
|
||||||
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
||||||
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
|
$span = (int)floor((strtotime($bed) - strtotime($bsd)) / 86400);
|
||||||
$first = true;
|
/* 🔴 v88(工单 #35):`$first` 原来是"窗口内遍历的第一个",**这是错的**。
|
||||||
foreach (self::eachDay($base, date('Y-m-d', strtotime($base . ' +' . $span . ' day')), $from, $to) as $d2) {
|
日视图只查一天时,如果那天正好是跨天日程的**中间天**,
|
||||||
$isLast = ($d2 === date('Y-m-d', strtotime($base . ' +' . $span . ' day')));
|
它就成了 `$first` ⇒ 被当作"开始日"处理(但 `time` 是空的),
|
||||||
|
于是落到全天行;同理 `$isLast` 也会把中间天当结束日。
|
||||||
|
⇒ 一律用**真实日期**跟 `$base`/`$base+span` 比较,不看遍历顺序。
|
||||||
|
|
||||||
|
另外把「这条跨度」本身也带出去(`span_days` / `seg`),
|
||||||
|
让端上/网页端不必再靠"同一次发生铺了几条"去猜 ——
|
||||||
|
那个推断在窗口被裁剪时(日视图)必然不成立。 */
|
||||||
|
$baseEnd = date('Y-m-d', strtotime($base . ' +' . $span . ' day'));
|
||||||
|
foreach (self::eachDay($base, $baseEnd, $from, $to) as $d2) {
|
||||||
|
$isFirst = ($d2 === $base);
|
||||||
|
$isLast = ($d2 === $baseEnd);
|
||||||
|
// 段形态:整天(span=0)就是 only
|
||||||
|
if ($span === 0) {
|
||||||
|
$segKind = 'only';
|
||||||
|
} elseif ($isFirst) {
|
||||||
|
$segKind = 'start';
|
||||||
|
} elseif ($isLast) {
|
||||||
|
$segKind = 'end';
|
||||||
|
} else {
|
||||||
|
$segKind = 'mid';
|
||||||
|
}
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'cal' => (int)$e['calendar_id'],
|
'cal' => (int)$e['calendar_id'],
|
||||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||||
@@ -1536,12 +1564,17 @@ final class Calendar
|
|||||||
'title' => (string)$vd['title'],
|
'title' => (string)$vd['title'],
|
||||||
'date' => $d2,
|
'date' => $d2,
|
||||||
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||||
'time' => ($first && empty($vd['all_day'])) ? $vtm : '',
|
'time' => ($isFirst && empty($vd['all_day'])) ? $vtm : '',
|
||||||
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
|
// 结束时刻同理:只落在**最后一天**上(那才是它真正结束的那天)
|
||||||
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
|
'end_time'=> ($isLast && $vetm !== '' && empty($vd['all_day'])) ? $vetm : '',
|
||||||
'all_day' => !empty($vd['all_day']),
|
'all_day' => !empty($vd['all_day']),
|
||||||
'kind' => 'custom',
|
'kind' => 'custom',
|
||||||
'ref_id' => (int)$e['id'],
|
'ref_id' => (int)$e['id'],
|
||||||
|
/* 跨度信息(v88):网页端据此把一条画成多列分段;
|
||||||
|
日视图下窗口只给一天也不会判错。
|
||||||
|
`span_days` = 跨了几天(0 表示当天来回)。 */
|
||||||
|
'span_days' => $span,
|
||||||
|
'seg' => $segKind,
|
||||||
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
|
// 🔴 v43(用户第 6 条):条目详情浮层要显示"更多信息",
|
||||||
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
|
// 所以地点/备注/状态也要带出去(原来模板只用得到 title/time)。
|
||||||
'location' => (string)($vd['location'] ?? ''),
|
'location' => (string)($vd['location'] ?? ''),
|
||||||
@@ -1553,7 +1586,6 @@ final class Calendar
|
|||||||
'_can_write' => $canW,
|
'_can_write' => $canW,
|
||||||
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
|
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
|
||||||
];
|
];
|
||||||
$first = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1612,6 +1644,9 @@ final class Calendar
|
|||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
|
"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.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
|
e.id AS event_id, e.title AS trip_title
|
||||||
FROM trip_nodes n
|
FROM trip_nodes n
|
||||||
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
||||||
@@ -1637,11 +1672,19 @@ final class Calendar
|
|||||||
'cal' => (int)$c['id'],
|
'cal' => (int)$c['id'],
|
||||||
'color' => (string)$c['color'],
|
'color' => (string)$c['color'],
|
||||||
'calname' => (string)$c['name'],
|
'calname' => (string)$c['name'],
|
||||||
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。
|
/* 🔴 #67(用户 2026-11-05,两条):
|
||||||
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。
|
① 「这个图标应该来自里面具体的交通工具,也就是说,如果是火车,
|
||||||
'title' => Trip::typeIcon((string)$n['node_type'])
|
就应该显示火车。」
|
||||||
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ')
|
⇒ 原来只按 `node_type` 取,`typeIcon('move')` 恒为 🚗 ——
|
||||||
. (string)$n['node_title'],
|
「赶路」永远显示汽车,哪怕实际坐的是火车。
|
||||||
|
改用 `leadIcon()`(出行方式优先、节点类型兜底),
|
||||||
|
它正是旅行详情页行程卡左侧用的同一个方法。
|
||||||
|
② 「后面这个对号是什么意思?…我感觉没必要,直接把这个对号去掉。」
|
||||||
|
⇒ 删掉原来插在标题里的 `' ✓ '`。 */
|
||||||
|
'title' => Trip::leadIcon((string)$n['node_type'],
|
||||||
|
(string)($n['travel_mode'] ?? ''),
|
||||||
|
$familyId)
|
||||||
|
. ' ' . (string)$n['node_title'],
|
||||||
'date' => $day,
|
'date' => $day,
|
||||||
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
|
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
|
||||||
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
|
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
|
||||||
@@ -1649,6 +1692,18 @@ final class Calendar
|
|||||||
'all_day' => $first && $tm === '00:00',
|
'all_day' => $first && $tm === '00:00',
|
||||||
'kind' => 'trip',
|
'kind' => 'trip',
|
||||||
'ref_id' => (int)$n['node_id'],
|
'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']])
|
'href' => url('trips/show', ['id' => (int)$n['event_id']])
|
||||||
. '#node-' . (int)$n['node_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
|
public static function cityFromText(string $text): string
|
||||||
{
|
{
|
||||||
$t = trim($text);
|
$t = trim($text);
|
||||||
@@ -636,6 +731,136 @@ final class Geo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
|
/** 高德 **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
|
public static function apiKey(): string
|
||||||
{
|
{
|
||||||
$a = fb_config('amap');
|
$a = fb_config('amap');
|
||||||
|
|||||||
+71
-8
@@ -399,9 +399,24 @@ final class Holiday
|
|||||||
if ($t === false || $end === false || $end < $t) {
|
if ($t === false || $end === false || $end < $t) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$years = [(int)substr($from, 0, 4)];
|
// 🔴 2026-11-02:原来只取「首年 + 末年」两年。
|
||||||
if ((int)substr($to, 0, 4) !== $years[0]) {
|
// 全量范围 1970~2100 下 ⇒ 只算了 1970 和 2100 的节气,
|
||||||
$years[] = (int)substr($to, 0, 4);
|
// 中间 128 年全丢(用户会觉得"日历上没节气")。
|
||||||
|
// 改成逐年遍历,并夹紧到农历算法支持范围 + 年数上限。
|
||||||
|
$yFrom = (int)substr($from, 0, 4);
|
||||||
|
$yTo = (int)substr($to, 0, 4);
|
||||||
|
if ($yFrom < Lunar::MIN_YEAR) {
|
||||||
|
$yFrom = Lunar::MIN_YEAR;
|
||||||
|
}
|
||||||
|
if ($yTo > Lunar::MAX_YEAR) {
|
||||||
|
$yTo = Lunar::MAX_YEAR;
|
||||||
|
}
|
||||||
|
if ($yTo - $yFrom + 1 > self::MAX_LUNAR_YEARS) {
|
||||||
|
$yTo = $yFrom + self::MAX_LUNAR_YEARS - 1;
|
||||||
|
}
|
||||||
|
$years = [];
|
||||||
|
for ($yy = $yFrom; $yy <= $yTo; $yy++) {
|
||||||
|
$years[] = $yy;
|
||||||
}
|
}
|
||||||
foreach ($years as $y) {
|
foreach ($years as $y) {
|
||||||
foreach (self::SOLAR_TERMS as $idx => $name) {
|
foreach (self::SOLAR_TERMS as $idx => $name) {
|
||||||
@@ -435,9 +450,25 @@ final class Holiday
|
|||||||
if ($t === false || $end === false || $end < $t) {
|
if ($t === false || $end === false || $end < $t) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// 逐日扫区间,转农历后查表 —— 区间很短(月视图 ≤42 天),成本可忽略。
|
// 逐日扫区间,转农历后查表 —— 天然处理"跨月/跨年"
|
||||||
// 这样天然处理"跨月/跨年"(如腊月二十三可能落在次年 1 月)。
|
// (如腊月二十三可能落在次年 1 月)。
|
||||||
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) {
|
//
|
||||||
|
// 🔴 2026-11-02:原来有 `$i < 400` 的硬上限。改成全量同步后请求范围是
|
||||||
|
// 1970~2100(47846 天)⇒ 只算前 400 天,**今年的春节/中秋全丢**。
|
||||||
|
// 改为与 lunarRange 一致的「夹范围 + 年数上限」。
|
||||||
|
$minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR));
|
||||||
|
$maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR));
|
||||||
|
if ($t < $minT) {
|
||||||
|
$t = $minT;
|
||||||
|
}
|
||||||
|
if ($end > $maxT) {
|
||||||
|
$end = $maxT;
|
||||||
|
}
|
||||||
|
$yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t);
|
||||||
|
if ($end > $yearsLimitT) {
|
||||||
|
$end = $yearsLimitT;
|
||||||
|
}
|
||||||
|
for (; $t <= $end; $t = strtotime('+1 day', $t)) {
|
||||||
$date = date('Y-m-d', $t);
|
$date = date('Y-m-d', $t);
|
||||||
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
|
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
|
||||||
$l = Lunar::solarToLunar($y, $m, $d);
|
$l = Lunar::solarToLunar($y, $m, $d);
|
||||||
@@ -695,6 +726,19 @@ final class Holiday
|
|||||||
*
|
*
|
||||||
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
|
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 单次请求最多算多少年的农历。
|
||||||
|
*
|
||||||
|
* 🔴 2026-11-02:原来是「最多 400 天」的硬上限,而客户端改成全量同步后
|
||||||
|
* 请求范围是 1970~2100(47846 天)⇒ 只返回前 400 天的农历,
|
||||||
|
* 导致**今天的农历是空的**。
|
||||||
|
*
|
||||||
|
* 改成按年计:客户端实际只需「今天 ±5 年」,
|
||||||
|
* 留 30 年余量足够,同时防止有人手拼 1900~2100 把服务器拖死
|
||||||
|
* (算 7 万天农历约需数十秒)。
|
||||||
|
*/
|
||||||
|
private const MAX_LUNAR_YEARS = 30;
|
||||||
|
|
||||||
public static function lunarRange(string $from, string $to): array
|
public static function lunarRange(string $from, string $to): array
|
||||||
{
|
{
|
||||||
$out = [];
|
$out = [];
|
||||||
@@ -703,8 +747,27 @@ final class Holiday
|
|||||||
if ($t === false || $end === false || $end < $t) {
|
if ($t === false || $end === false || $end < $t) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天)
|
|
||||||
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) {
|
// ① 夹到农历算法支持的范围(1900-01-31 ~ 2100-12-31),
|
||||||
|
// 否则 solarToLunar 会一路返回 null,白跑空转。
|
||||||
|
$minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR));
|
||||||
|
$maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR));
|
||||||
|
if ($t < $minT) {
|
||||||
|
$t = $minT;
|
||||||
|
}
|
||||||
|
if ($end > $maxT) {
|
||||||
|
$end = $maxT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 年数上限:超出就截断到 MAX_LUNAR_YEARS 年末
|
||||||
|
$yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t);
|
||||||
|
if ($end > $yearsLimitT) {
|
||||||
|
$end = $yearsLimitT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 逐日生成(农历必须逐日算,无法按年跳)
|
||||||
|
// 原来的 `$i < 400` 计数制已由上面的年数上限取代。
|
||||||
|
for (; $t <= $end; $t = strtotime('+1 day', $t)) {
|
||||||
$date = date('Y-m-d', $t);
|
$date = date('Y-m-d', $t);
|
||||||
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
|
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
|
||||||
$l = Lunar::solarToLunar($y, $m, $d);
|
$l = Lunar::solarToLunar($y, $m, $d);
|
||||||
|
|||||||
+307
@@ -57,6 +57,187 @@ final class Ics
|
|||||||
'RECURRENCE-ID',
|
'RECURRENCE-ID',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个待办集合导出成 .ics 文本(VTODO 版本)。
|
||||||
|
*
|
||||||
|
* 🔴 2026-11-02 新增:用户「网页端 caldav 除了同步日程,增加同步任务(待办)」。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不复用 `export()`:`export()` 里每条都走 `vevent()`,
|
||||||
|
* 而 VEVENT 与 VTODO 是**两种组件**(RFC 5545 §3.6.1 / §3.6.2):
|
||||||
|
* VEVENT 必须有 DTSTART、VTODO 不能有 DTEND。混用会让客户端解析失败。
|
||||||
|
* ⇒ 单独一个入口,但**共用** fold/escape 等底层工具,风格保持一致。
|
||||||
|
*
|
||||||
|
* @param array $tasks tasks 表的行(已补好 uid / board_name)
|
||||||
|
* @param string $calName X-WR-CALNAME 用的名字
|
||||||
|
*/
|
||||||
|
public static function exportTodos(array $tasks, string $calName = '待办'): string
|
||||||
|
{
|
||||||
|
$out = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:' . self::PRODID,
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'X-WR-CALNAME:' . self::escapeText($calName),
|
||||||
|
/* VTODO 是 CalDAV 的扩展能力。这两个 non-standard 头是
|
||||||
|
iOS / 苹果提醒事项认「这是个待办本」的依据之一;
|
||||||
|
不认识它们的客户端会忽略,不影响 VEVENT 本。 */
|
||||||
|
'X-WR-RELCALID:familybook-tasks',
|
||||||
|
'X-PUBLISHED-TTL:PT1H',
|
||||||
|
];
|
||||||
|
foreach ($tasks as $t) {
|
||||||
|
$out = array_merge($out, self::vtodo($t));
|
||||||
|
}
|
||||||
|
$out[] = 'END:VCALENDAR';
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
foreach ($out as $line) {
|
||||||
|
foreach (self::fold($line) as $l) {
|
||||||
|
$lines[] = $l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode("\r\n", $lines) . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条任务 → VTODO 的若干行。
|
||||||
|
*
|
||||||
|
* ⚠️ VTODO **不能有 DTSTART 之外的时间边界**:`DTEND` 是 VEVENT 的属性,
|
||||||
|
* 用在 VTODO 上会被严格客户端判为非法而整条丢弃。
|
||||||
|
* 截止时间只走 `DUE`。
|
||||||
|
*/
|
||||||
|
private static function vtodo(array $t): array
|
||||||
|
{
|
||||||
|
$uid = (string)($t['uid'] ?? '');
|
||||||
|
if ($uid === '') {
|
||||||
|
$uid = 'task-' . (int)($t['id'] ?? 0);
|
||||||
|
}
|
||||||
|
$stamp = self::stampOf($t['updated_at'] ?? $t['created_at'] ?? null);
|
||||||
|
$status = (string)($t['status'] ?? 'todo');
|
||||||
|
|
||||||
|
$out = ['BEGIN:VTODO', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
|
||||||
|
|
||||||
|
/* ---- 完成时间:必须在 STATUS:COMPLETED 之前给,
|
||||||
|
苹果提醒事项要求 COMPLETED 与 STATUS 同时出现才认"已完成" ---- */
|
||||||
|
$doneAt = (string)($t['done_at'] ?? '');
|
||||||
|
if ($status === self::DONE_STATUS && $doneAt !== '') {
|
||||||
|
$out[] = 'COMPLETED:' . self::dateTimeValue($doneAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($t['created_at'] ?? '') !== '') {
|
||||||
|
$out[] = 'CREATED:' . self::dateTimeValue((string)$t['created_at']);
|
||||||
|
}
|
||||||
|
if (($t['updated_at'] ?? '') !== '') {
|
||||||
|
$out[] = 'LAST-MODIFIED:' . self::dateTimeValue((string)$t['updated_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 标题 ----
|
||||||
|
🔴 2026-11-02 改:原来给子任务硬加「└ 」前缀。那会**污染用户数据** ——
|
||||||
|
客户端把标题写回来(PUT)时,前缀会被存进数据库,
|
||||||
|
用户的任务标题就凭空多了一坨。
|
||||||
|
层级改由 RELATED-TO 表达(标准属性),
|
||||||
|
另给 X-FAMILYBOOK-PARENT 做展示用的补充,不碰 SUMMARY。 */
|
||||||
|
$title = (string)($t['title'] ?? '');
|
||||||
|
$out[] = 'SUMMARY:' . self::escapeText($title);
|
||||||
|
|
||||||
|
$parentId = (int)($t['parent_id'] ?? 0);
|
||||||
|
$note = (string)($t['note'] ?? '');
|
||||||
|
/* 跨本父子:客户端会看到 RELATED-TO 指向一个本里不存在的 UID,
|
||||||
|
部分客户端会**直接不显示这条**(当成孤立子项)。
|
||||||
|
补一句人话说明,让用户知道这是"父在别的分类里",不是数据损坏。 */
|
||||||
|
if (!empty($t['parent_in_other_book']) && ($t['parent_title'] ?? '') !== '') {
|
||||||
|
$hint = '(此任务的上级「' . (string)$t['parent_title']
|
||||||
|
. '」在另一个分类里,网页端可以看到两级的完整关系)';
|
||||||
|
$note = $note === '' ? $hint : $note . "\n\n" . $hint;
|
||||||
|
}
|
||||||
|
if ($note !== '') {
|
||||||
|
$out[] = 'DESCRIPTION:' . self::escapeText($note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 截止时间 ----
|
||||||
|
库里 `due_at` 是 DATETIME。判断"是否整天"的方式:时分秒全 00:00:00
|
||||||
|
就按 DATE 输出(用户在网页上选"某天截止"通常就是这个形态),
|
||||||
|
否则按 DATE-TIME 输出。这样客户端显示的粒度与网页端一致。 */
|
||||||
|
$due = (string)($t['due_at'] ?? '');
|
||||||
|
if ($due !== '') {
|
||||||
|
if (self::isMidnight($due)) {
|
||||||
|
$out[] = 'DUE;VALUE=DATE:' . self::dateValue($due);
|
||||||
|
} else {
|
||||||
|
$out[] = 'DUE:' . self::dateTimeValue($due);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 状态 ----
|
||||||
|
$out[] = 'STATUS:' . self::statusOf($status);
|
||||||
|
$out[] = 'PERCENT-COMPLETE:' . self::percentOf($status);
|
||||||
|
|
||||||
|
/* ---- 看板名进 CATEGORIES ----
|
||||||
|
客户端大多支持按 CATEGORIES 分组/打标签,把看板名放这里
|
||||||
|
是"让客户端也能看出这条属于哪个看板"的最低成本方式。
|
||||||
|
⚠️ 派生只读,不参与反向写回(PUT 时不从 CATEGORIES 改看板)。 */
|
||||||
|
$board = (string)($t['board_name'] ?? '');
|
||||||
|
if ($board !== '') {
|
||||||
|
$out[] = 'CATEGORIES:' . self::escapeText($board);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 父子关系 ----
|
||||||
|
`RELATED-TO` 是 RFC 5545 §3.8.4.5 的标准属性,值是**父资源的 UID**。
|
||||||
|
本模块的 UID 规范是 `task-<id>`,所以这里拼 `task-<parentId>`
|
||||||
|
正好对上同一本里那个资源的 UID。
|
||||||
|
|
||||||
|
⚠️ 各客户端的支持度:
|
||||||
|
· DAVx⁵ + Tasks.org:认,显示成缩进的子项
|
||||||
|
· 苹果提醒事项:认自己的 parent 语义,对 RELATED-TO 支持有限
|
||||||
|
· Thunderbird:完全不显示层级,两条并列
|
||||||
|
这是客户端能力差异,服务端无法改变 —— 数据是正确的。 */
|
||||||
|
if ($parentId > 0) {
|
||||||
|
$out[] = 'RELATED-TO:task-' . $parentId;
|
||||||
|
/* 展示用的补充属性:值 = 父标题。
|
||||||
|
支持的客户端能直接显示"上级是谁",不支持的无视。
|
||||||
|
用 X- 前缀是因为这是本服务特有的,不进标准语义。 */
|
||||||
|
if (($t['parent_title'] ?? '') !== '') {
|
||||||
|
$out[] = 'X-FAMILYBOOK-PARENT:' . self::escapeText((string)$t['parent_title']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = 'END:VTODO';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务的三种状态 → VTODO 的 STATUS 值 */
|
||||||
|
private static function statusOf(string $st): string
|
||||||
|
{
|
||||||
|
if ($st === self::DONE_STATUS) {
|
||||||
|
return 'COMPLETED';
|
||||||
|
}
|
||||||
|
if ($st === 'doing') {
|
||||||
|
return 'IN-PROCESS';
|
||||||
|
}
|
||||||
|
return 'NEEDS-ACTION';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STATUS 的通用信号:不认 IN-PROCESS 的客户端也能看出进度 */
|
||||||
|
private static function percentOf(string $st): int
|
||||||
|
{
|
||||||
|
if ($st === self::DONE_STATUS) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return $st === 'doing' ? 50 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个 DATETIME 是不是"零点"(= 用户表达的是"某天"而不是"某时刻") */
|
||||||
|
private static function isMidnight(string $dt): bool
|
||||||
|
{
|
||||||
|
$t = strtotime($dt);
|
||||||
|
if ($t === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return date('H:i:s', $t) === '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务完成状态常量(与 App\\Task::DONE_STATUS 一致,这里独立一份避免循环依赖) */
|
||||||
|
private const DONE_STATUS = 'done';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一个日历本导出成 .ics 文本。
|
* 把一个日历本导出成 .ics 文本。
|
||||||
*
|
*
|
||||||
@@ -362,6 +543,132 @@ final class Ics
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 VTODO(客户端 PUT 上来的待办)。
|
||||||
|
*
|
||||||
|
* 🔴 2026-11-02 新增。**独立于** `parse()`:
|
||||||
|
* `parse()` 只收 VEVENT,改它会波及日历写路径(那里注释记了多轮踩坑史)。
|
||||||
|
*
|
||||||
|
* @return array<int,array> 每条的字段:
|
||||||
|
* uid / summary / description / due / status / percent / completed /
|
||||||
|
* priority / categories / parent_uid / extra
|
||||||
|
*
|
||||||
|
* ⚠️ 只取**第一个** VTODO:一个 CalDAV 资源对应一个待办,
|
||||||
|
* 正文里出现多个说明客户端行为异常,宁可少存不可乱存
|
||||||
|
* (与 PUT 里 VEVENT 的处理口径一致)。
|
||||||
|
*/
|
||||||
|
public static function parseTodos(string $text): array
|
||||||
|
{
|
||||||
|
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||||
|
$text = preg_replace("/\n[ \t]/", '', $text) ?? $text;
|
||||||
|
$lines = explode("\n", $text);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$cur = null;
|
||||||
|
foreach ($lines as $raw) {
|
||||||
|
$line = trim($raw);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($line === 'BEGIN:VTODO') {
|
||||||
|
$cur = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($line === 'END:VTODO') {
|
||||||
|
if ($cur !== null) {
|
||||||
|
$out[] = $cur;
|
||||||
|
}
|
||||||
|
$cur = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($cur === null) {
|
||||||
|
continue; // VCALENDAR / VALARM / VEVENT 里的东西跳过
|
||||||
|
}
|
||||||
|
[$name, $params, $value] = self::splitProp($line);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 同名只留第一个(VTODO 里这些属性都是单值的)
|
||||||
|
if (!array_key_exists($name, $cur)) {
|
||||||
|
$cur[$name] = $value;
|
||||||
|
$cur[$name . '_PARAMS'] = $params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把解析出来的 VTODO 属性归一成能落库的形状。
|
||||||
|
*
|
||||||
|
* 与 `normalizeVevent()` 并列,互不影响。
|
||||||
|
*
|
||||||
|
* @return array{uid:string, summary:string, description:string,
|
||||||
|
* due:?string, status:string, completed:?string,
|
||||||
|
* percent:?int, priority:?int, categories:string,
|
||||||
|
* parent_uid:string}
|
||||||
|
*/
|
||||||
|
public static function normalizeTodo(array $p): array
|
||||||
|
{
|
||||||
|
$summary = self::unescapeText((string)($p['SUMMARY'] ?? ''));
|
||||||
|
$desc = self::unescapeText((string)($p['DESCRIPTION'] ?? ''));
|
||||||
|
|
||||||
|
// ---- DUE:可能是 DATE 也可能 DATE-TIME ----
|
||||||
|
$due = null;
|
||||||
|
$dueRaw = (string)($p['DUE'] ?? '');
|
||||||
|
if ($dueRaw !== '') {
|
||||||
|
$dueParams = (string)($p['DUE_PARAMS'] ?? '');
|
||||||
|
if (stripos($dueParams, 'VALUE=DATE') !== false && stripos($dueParams, 'DATE-TIME') === false) {
|
||||||
|
// 整天:`fromDateValue()` 返回的已经是 `Y-m-d 00:00:00` 完整形态,
|
||||||
|
// ⚠️ 不要再拼 ` 00:00:00` —— 会变成 `... 00:00:00 00:00:00`(踩过)
|
||||||
|
$due = self::fromDateValue($dueRaw);
|
||||||
|
} else {
|
||||||
|
$due = self::fromDateTimeValue($dueRaw, $dueParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- STATUS / COMPLETED ----
|
||||||
|
/* 完成态的判定优先级:
|
||||||
|
① `STATUS:COMPLETED` —— 最明确
|
||||||
|
② 有 `COMPLETED` 时间戳 —— 苹果提醒事项勾选时只加这个
|
||||||
|
③ `PERCENT-COMPLETE:100` —— 少数客户端只用这个
|
||||||
|
三者任一成立就算已完成,否则再看 IN-PROCESS。 */
|
||||||
|
$stRaw = strtoupper(trim((string)($p['STATUS'] ?? '')));
|
||||||
|
$compRaw = (string)($p['COMPLETED'] ?? '');
|
||||||
|
$pctRaw = (string)($p['PERCENT-COMPLETE'] ?? '');
|
||||||
|
$completed = $compRaw !== ''
|
||||||
|
? self::fromDateTimeValue($compRaw, (string)($p['COMPLETED_PARAMS'] ?? ''))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$isDone = $stRaw === 'COMPLETED'
|
||||||
|
|| $completed !== null
|
||||||
|
|| (trim($pctRaw) !== '' && (int)$pctRaw >= 100);
|
||||||
|
|
||||||
|
if ($isDone) {
|
||||||
|
$status = self::DONE_STATUS;
|
||||||
|
} elseif ($stRaw === 'IN-PROCESS' || $stRaw === 'IN_PROGRESS') {
|
||||||
|
$status = 'doing';
|
||||||
|
} elseif (trim($pctRaw) !== '' && (int)$pctRaw > 0 && (int)$pctRaw < 100) {
|
||||||
|
$status = 'doing';
|
||||||
|
} else {
|
||||||
|
$status = 'todo';
|
||||||
|
}
|
||||||
|
|
||||||
|
$prio = trim((string)($p['PRIORITY'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => trim((string)($p['UID'] ?? '')),
|
||||||
|
'summary' => $summary,
|
||||||
|
'description' => $desc,
|
||||||
|
'due' => $due,
|
||||||
|
'status' => $status,
|
||||||
|
'completed' => $completed,
|
||||||
|
'percent' => $pctRaw === '' ? null : (int)$pctRaw,
|
||||||
|
'priority' => $prio === '' ? null : (int)$prio,
|
||||||
|
'categories' => self::unescapeText((string)($p['CATEGORIES'] ?? '')),
|
||||||
|
'parent_uid' => trim((string)($p['RELATED-TO'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把解析出来的属性归一成我们要落库的形状。
|
* 把解析出来的属性归一成我们要落库的形状。
|
||||||
*
|
*
|
||||||
|
|||||||
+83
-22
@@ -95,8 +95,12 @@ final class Movie
|
|||||||
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
|
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
|
||||||
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
|
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
|
||||||
{
|
{
|
||||||
|
/* 🔴 2026-11-06:**不再 SELECT `ticket_key`**。
|
||||||
|
用户要求"只分两类:海报、观影照片",旧票根已经一次性迁进
|
||||||
|
`event_photos`(见迁移脚本与 Schema VERSION 37 的说明),
|
||||||
|
字段本身也置 NULL 了。继续查它出来只会让上层又长出第三个概念。 */
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta,
|
'SELECT e.*, d.info, d.poster_key, d.cinema_id, d.douban_meta,
|
||||||
c.place AS cinema_place, c.name AS cinema_name,
|
c.place AS cinema_place, c.name AS cinema_name,
|
||||||
c.address AS cinema_address, c.color AS cinema_color
|
c.address AS cinema_address, c.color AS cinema_color
|
||||||
FROM events e
|
FROM events e
|
||||||
@@ -133,8 +137,16 @@ final class Movie
|
|||||||
|
|
||||||
public static function photos(int $eventId): array
|
public static function photos(int $eventId): array
|
||||||
{
|
{
|
||||||
|
/* 🔴 2026-11-06:带上 `kind` / `thumb_key` / `mime`(观影照片支持视频)。
|
||||||
|
⚠️ 用 `COALESCE` 兜底 `kind`:极老的库(迁移没跑过)可能没这列,
|
||||||
|
但这种情形会直接报 1054 —— 迁移一定会先跑,这里不加 COALESCE。
|
||||||
|
真正需要兜底的是**值为 NULL** 的历史行(迁移加列时默认 'image',
|
||||||
|
但手写过 INSERT 的行可能是 NULL)。 */
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id'
|
"SELECT id, `key`,
|
||||||
|
COALESCE(`kind`, 'image') AS kind,
|
||||||
|
`thumb_key`, `mime`
|
||||||
|
FROM event_photos WHERE event_id = ? ORDER BY position, id"
|
||||||
);
|
);
|
||||||
$st->execute([$eventId]);
|
$st->execute([$eventId]);
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
@@ -143,7 +155,8 @@ final class Movie
|
|||||||
/**
|
/**
|
||||||
* 新建一条观影记录,返回事件 id。
|
* 新建一条观影记录,返回事件 id。
|
||||||
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
|
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
|
||||||
* cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[])
|
* cinema_id(int|null), poster_key, attendees(int[]), photos(string[])
|
||||||
|
* ⚠️ 2026-11-06:`ticket_key` 已取消(票根并入 photos)。
|
||||||
*/
|
*/
|
||||||
public static function create(int $familyId, ?int $creatorId, array $d): int
|
public static function create(int $familyId, ?int $creatorId, array $d): int
|
||||||
{
|
{
|
||||||
@@ -203,17 +216,20 @@ final class Movie
|
|||||||
$row = [
|
$row = [
|
||||||
self::textOrNull($d['info'] ?? ''),
|
self::textOrNull($d['info'] ?? ''),
|
||||||
self::keyOrNull($d['poster_key'] ?? ''),
|
self::keyOrNull($d['poster_key'] ?? ''),
|
||||||
self::keyOrNull($d['ticket_key'] ?? ''),
|
|
||||||
$cid > 0 ? $cid : null,
|
$cid > 0 ? $cid : null,
|
||||||
$meta,
|
$meta,
|
||||||
];
|
];
|
||||||
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
|
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
|
||||||
$has->execute([$eventId]);
|
$has->execute([$eventId]);
|
||||||
|
/* 🔴 2026-11-06:`ticket_key` 从这条 UPSERT 里**移除**了。
|
||||||
|
票根概念已取消(迁进 `event_photos`),再写它等于把它复活。
|
||||||
|
⚠️ 列还留在表里(不清字段,留作历史审计),但代码不再碰它;
|
||||||
|
迁移已把存量置 NULL,所以它现在是全 NULL 的死列。 */
|
||||||
if ((int)$has->fetchColumn() > 0) {
|
if ((int)$has->fetchColumn() > 0) {
|
||||||
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
|
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
|
||||||
->execute([...$row, $eventId]);
|
->execute([...$row, $eventId]);
|
||||||
} else {
|
} else {
|
||||||
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)')
|
$db->prepare('INSERT INTO movie_details (info, poster_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?)')
|
||||||
->execute([...$row, $eventId]);
|
->execute([...$row, $eventId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,14 +310,34 @@ final class Movie
|
|||||||
*/
|
*/
|
||||||
public static function addPhotos(int $eventId, array $keys): void
|
public static function addPhotos(int $eventId, array $keys): void
|
||||||
{
|
{
|
||||||
$clean = [];
|
/* ⚠️ 兼容两种入参(2026-11-06 起):
|
||||||
foreach ($keys as $k) {
|
· `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
|
||||||
$k = trim((string)$k);
|
· `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
|
||||||
if ($k !== '') {
|
—— 新调用方(带类型信息)
|
||||||
$clean[] = $k;
|
统一归一成"每项一个数组",下面只有一条 INSERT 路径,
|
||||||
|
不会出现"有的走老分支有的走新分支"那种分叉。 */
|
||||||
|
$rows = [];
|
||||||
|
foreach ($keys as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$k = trim((string)($item['key'] ?? ''));
|
||||||
|
if ($k === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'key' => $k,
|
||||||
|
'kind' => ((string)($item['kind'] ?? 'image')) === 'video' ? 'video' : 'image',
|
||||||
|
'thumb_key' => (string)($item['thumb_key'] ?? ''),
|
||||||
|
'mime' => (string)($item['mime'] ?? ''),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$k = trim((string)$item);
|
||||||
|
if ($k === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = ['key' => $k, 'kind' => 'image', 'thumb_key' => '', 'mime' => ''];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($clean === []) {
|
if ($rows === []) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
@@ -309,24 +345,41 @@ final class Movie
|
|||||||
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
|
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
|
||||||
$st->execute([$eventId]);
|
$st->execute([$eventId]);
|
||||||
$pos = (int)$st->fetchColumn();
|
$pos = (int)$st->fetchColumn();
|
||||||
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
|
$ins = $db->prepare(
|
||||||
foreach ($clean as $k) {
|
'INSERT INTO event_photos (event_id, `key`, kind, thumb_key, mime, position)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($rows as $r) {
|
||||||
$pos += 10;
|
$pos += 10;
|
||||||
$ins->execute([$eventId, $k, $pos]);
|
$ins->execute([
|
||||||
|
$eventId, $r['key'], $r['kind'],
|
||||||
|
$r['thumb_key'] !== '' ? $r['thumb_key'] : null,
|
||||||
|
$r['mime'] !== '' ? $r['mime'] : null,
|
||||||
|
$pos,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删一张现场照片(连带删存储对象) */
|
/** 删一张现场照片(连带删存储对象) */
|
||||||
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
|
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
|
||||||
{
|
{
|
||||||
$st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?');
|
/* 🔴 2026-11-06:视频的**封面**也要一起删(`thumb_key`),
|
||||||
|
否则每删一个视频就在存储里留一张孤儿封面。 */
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT `key`, `thumb_key` FROM event_photos WHERE id = ? AND event_id = ?'
|
||||||
|
);
|
||||||
$st->execute([$photoId, $eventId]);
|
$st->execute([$photoId, $eventId]);
|
||||||
$key = (string)($st->fetchColumn() ?: '');
|
$row = $st->fetch();
|
||||||
|
$key = $row ? (string)($row['key'] ?? '') : '';
|
||||||
|
$thumb = $row ? (string)($row['thumb_key'] ?? '') : '';
|
||||||
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
|
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
|
||||||
->execute([$photoId, $eventId]);
|
->execute([$photoId, $eventId]);
|
||||||
if ($key !== '') {
|
if ($key !== '') {
|
||||||
Storage\Storage::delete($familyId, $key);
|
Storage\Storage::delete($familyId, $key);
|
||||||
}
|
}
|
||||||
|
if ($thumb !== '' && $thumb !== $key) {
|
||||||
|
Storage\Storage::delete($familyId, $thumb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
|
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
|
||||||
@@ -335,16 +388,24 @@ final class Movie
|
|||||||
$keys = [];
|
$keys = [];
|
||||||
foreach (self::photos($id) as $p) {
|
foreach (self::photos($id) as $p) {
|
||||||
$keys[] = (string)$p['key'];
|
$keys[] = (string)$p['key'];
|
||||||
|
/* 视频的封面也要清(2026-11-06) */
|
||||||
|
$tk = (string)($p['thumb_key'] ?? '');
|
||||||
|
if ($tk !== '') {
|
||||||
|
$keys[] = $tk;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/* 🔴 2026-11-06:票根已并入 `event_photos`,上面的 photos() 循环就把
|
||||||
|
它收集进来了 —— 原来这里还要单独查一次 `ticket_key` 并删对象,
|
||||||
|
现在**不需要且不该要**(字段已置 NULL,查出来也是空)。
|
||||||
|
⚠️ 海报仍单独存在 `movie_details.poster_key`,所以要留这一段。 */
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
|
'SELECT poster_key FROM movie_details WHERE event_id = ?'
|
||||||
);
|
);
|
||||||
$st->execute([$id]);
|
$st->execute([$id]);
|
||||||
if ($row = $st->fetch()) {
|
if ($row = $st->fetch()) {
|
||||||
foreach ([$row['poster_key'], $row['ticket_key']] as $k) {
|
$k = (string)($row['poster_key'] ?? '');
|
||||||
if ($k) {
|
if ($k !== '') {
|
||||||
$keys[] = (string)$k;
|
$keys[] = $k;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
|
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
|
||||||
|
|||||||
+40
-3
@@ -298,7 +298,12 @@ final class Note
|
|||||||
$used = [];
|
$used = [];
|
||||||
foreach (self::blocks($noteId) as $b) {
|
foreach (self::blocks($noteId) as $b) {
|
||||||
if ((string)$b['kind'] === 'image') {
|
if ((string)$b['kind'] === 'image') {
|
||||||
$used[(string)$b['content']] = true;
|
// 🔴 工单 #18:content 可能带 `|宽度`,必须剥掉再比;
|
||||||
|
// 否则"调过宽度的图"排除不掉,会既当图片块又在附件区重复出现。
|
||||||
|
$k = self::imageKey((string)$b['content']);
|
||||||
|
if ($k !== '') {
|
||||||
|
$used[$k] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$out = [];
|
$out = [];
|
||||||
@@ -452,6 +457,34 @@ final class Note
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图片块的 content 里取出**真正的附件 key**(剥掉 `|宽度` 后缀)。
|
||||||
|
*
|
||||||
|
* 🔴 工单 #18(用户 2026-10-28「笔记功能,上传图片失败」):图片块 content 有两种形态
|
||||||
|
* —— `photos/xxx.jpg` 与 `photos/xxx.jpg|50`(前端 `serialize()` 在宽度不是 100 时写后缀)。
|
||||||
|
* **凡是拿 content 当 key 用的地方都必须先过这里**,否则带后缀的那个永远匹配不到。
|
||||||
|
*
|
||||||
|
* ⚠️ 曾经的 bug:`claimUploads()` 与 `files()` 直接拿 content 匹配 `attachments.key`,
|
||||||
|
* 于是"把图片调成 50% 再保存" ⇒ 草稿附件(target_id=0)认领不到 ⇒ 图片变孤儿,
|
||||||
|
* 用户看到的就是**图片上传失败 / 保存后图没了**。
|
||||||
|
* 默认 100% 不写后缀,所以表现为"有时好有时坏",极易被误判成上传问题。
|
||||||
|
*
|
||||||
|
* ⚠️ 修在**服务端**而不是"前端别写后缀":后者会把宽度功能废掉
|
||||||
|
* (渲染时要按 content 里的宽度出图,见 templates/notes/form.php 的 $imgParts)。
|
||||||
|
*
|
||||||
|
* @param string $content 图片块 content,形如 `abc` 或 `abc|50`
|
||||||
|
* @return string 纯 key(可能为空串)
|
||||||
|
*/
|
||||||
|
public static function imageKey(string $content): string
|
||||||
|
{
|
||||||
|
$c = trim($content);
|
||||||
|
if ($c === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$p = explode('|', $c, 2);
|
||||||
|
return trim($p[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
|
* 把本次上传的图片块附件从"草稿态"认领到这条笔记上。
|
||||||
*
|
*
|
||||||
@@ -469,8 +502,12 @@ final class Note
|
|||||||
{
|
{
|
||||||
$keys = [];
|
$keys = [];
|
||||||
foreach ($blocks as $b) {
|
foreach ($blocks as $b) {
|
||||||
if ($b['kind'] === 'image' && $b['content'] !== '') {
|
if ($b['kind'] === 'image') {
|
||||||
$keys[] = $b['content'];
|
// 🔴 工单 #18:必须先剥 `|宽度` 后缀,否则带后缀的 key 永远匹配不到
|
||||||
|
$k = self::imageKey((string)$b['content']);
|
||||||
|
if ($k !== '') {
|
||||||
|
$keys[] = $k;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
|||||||
+99
-3
@@ -188,7 +188,7 @@ final class Schema
|
|||||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||||
*/
|
*/
|
||||||
public const VERSION = '35';
|
public const VERSION = '37';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||||
@@ -539,12 +539,15 @@ CREATE TABLE IF NOT EXISTS `event_photos` (
|
|||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`event_id` INT UNSIGNED NOT NULL,
|
`event_id` INT UNSIGNED NOT NULL,
|
||||||
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
|
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
|
||||||
|
`kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)',
|
||||||
|
`thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)',
|
||||||
|
`mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)',
|
||||||
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
|
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_ep_event` (`event_id`, `position`),
|
KEY `idx_ep_event` (`event_id`, `position`),
|
||||||
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片/视频(可多张)'
|
||||||
SQL,
|
SQL,
|
||||||
// ---- 统一附件(v12)----
|
// ---- 统一附件(v12)----
|
||||||
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
|
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
|
||||||
@@ -965,7 +968,7 @@ SQL,
|
|||||||
<<<'SQL'
|
<<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `nav_groups` (
|
CREATE TABLE IF NOT EXISTS `nav_groups` (
|
||||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
`person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(家书 person id)',
|
`person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(岁月家 person id)',
|
||||||
`name` VARCHAR(60) NOT NULL,
|
`name` VARCHAR(60) NOT NULL,
|
||||||
`slug` VARCHAR(60) NOT NULL DEFAULT '',
|
`slug` VARCHAR(60) NOT NULL DEFAULT '',
|
||||||
`display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页',
|
`display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页',
|
||||||
@@ -1542,6 +1545,22 @@ SQL,
|
|||||||
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
|
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
|
||||||
self::addColumn($db, 'trip_nodes', 'revision_n',
|
self::addColumn($db, 'trip_nodes', 'revision_n',
|
||||||
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
|
"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) {
|
self::tryStep('建行程修正历史表', static function () use ($db) {
|
||||||
$db->exec(<<<'SQL'
|
$db->exec(<<<'SQL'
|
||||||
@@ -1842,6 +1861,83 @@ SQL);
|
|||||||
self::addColumn($db, 'trip_details', 'is_public',
|
self::addColumn($db, 'trip_details', 'is_public',
|
||||||
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
|
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
|
||||||
|
|
||||||
|
// ---- 观影照片支持视频(版本 36,2026-11-06)----
|
||||||
|
/* 🔴 用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改,
|
||||||
|
要求能够上传视频,视频需要转码,参考旅程中的行程做法。」
|
||||||
|
|
||||||
|
事实核查:**原来不允许**。`POST /movies/<id>/photos` 走的是
|
||||||
|
`Upload::image()`,那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩放转 JPEG),
|
||||||
|
传视频会被直接拒("只支持 JPG / PNG / GIF / WebP 图片")。
|
||||||
|
旅程那边走的是 `Attachment::save()`,它内部对视频调 `Video::toH264()`
|
||||||
|
转成 H.264 再存 —— 所以观影要支持视频,得把照片也改走那条路。
|
||||||
|
|
||||||
|
改走 Attachment 后有三件事要落到 `event_photos` 上:
|
||||||
|
· `kind` —— 是图片还是视频。端上要靠它决定"显示图"还是"显示播放按钮";
|
||||||
|
(不靠扩展名猜:`.mp4` 也可能是图片的误命名,MIME 才靠谱)
|
||||||
|
· `thumb_key` —— 视频首帧封面。列表里不可能为每个视频解一次码
|
||||||
|
(一屏 9 个视频 × 解码 = 卡死),必须服务端抽一帧存成图;
|
||||||
|
· `mime` —— 冗余存一份,免得每次都要回查 Storage 或猜。
|
||||||
|
|
||||||
|
⚠️ 三列都给默认值:`kind` 默认 `'image'`,老数据(全是照片)自动正确,
|
||||||
|
不需要回填 UPDATE —— 这是最安全的一种迁移默认值。 */
|
||||||
|
self::addColumn($db, 'event_photos', 'kind',
|
||||||
|
"ADD COLUMN `kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)' AFTER `key`");
|
||||||
|
self::addColumn($db, 'event_photos', 'thumb_key',
|
||||||
|
"ADD COLUMN `thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)' AFTER `kind`");
|
||||||
|
self::addColumn($db, 'event_photos', 'mime',
|
||||||
|
"ADD COLUMN `mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)' AFTER `thumb_key`");
|
||||||
|
|
||||||
|
// ---- 「票根」概念取消,并入观影照片(版本 37,2026-11-06)----
|
||||||
|
/* 🔴 用户原话:「现在不是已经没有票根的概念了吗?全部都换成观影照片了。
|
||||||
|
别管是票根还是观影照片,都在一起。现在只分两类:一类是海报,
|
||||||
|
一类是观影照片。海报是来自豆瓣的,这个不用管它,主要就是观影照片,
|
||||||
|
所有的都在观影照片里边。」
|
||||||
|
|
||||||
|
背景:早年「票根」是 `movie_details.ticket_key` 的**单张**槽位,
|
||||||
|
和 `event_photos`(现场照片)是两条并行的路径。v35 用户在网页端就说过
|
||||||
|
"不分类别,放一块",但当时只在**模板层**把它画进同一个网格,
|
||||||
|
数据层仍然两条路 —— 结果是端上永远得写"票根另算"的特判,
|
||||||
|
而且票根没有 `event_photos` 行 id,**删不掉**。
|
||||||
|
|
||||||
|
这一版把数据也统一:
|
||||||
|
① 把每条记录的 `ticket_key` 插进 `event_photos`(position 排最前,
|
||||||
|
因为票根是"这场电影的凭证",用户认知里它排第一);
|
||||||
|
② `ticket_key` 置 NULL(字段保留,仅作历史审计,代码不再读写)。
|
||||||
|
|
||||||
|
⚠️ **幂等**:靠"该 key 是否已在该 event 的 event_photos 里"判断,
|
||||||
|
重复跑不会插重复行 —— 迁移在每次升级检查时都可能被调到。
|
||||||
|
⚠️ **不删存储对象**:只是搬引用,文件原地不动,失败也不会丢图。
|
||||||
|
⚠️ 2026-11-06 线上已手工跑过同一段逻辑(220 条全部搬完,
|
||||||
|
复核剩余旧票根=0、event_photos 234 条),所以这里对老库是空操作;
|
||||||
|
写进来是为了让**新库/重装库**走同一套,不靠"记得手工跑脚本"。 */
|
||||||
|
$tkRows = $db->query(
|
||||||
|
"SELECT event_id, ticket_key FROM movie_details
|
||||||
|
WHERE ticket_key IS NOT NULL AND ticket_key <> ''"
|
||||||
|
)->fetchAll();
|
||||||
|
if (count($tkRows) > 0) {
|
||||||
|
$hasPhoto = $db->prepare('SELECT COUNT(*) FROM event_photos WHERE event_id = ? AND `key` = ?');
|
||||||
|
$minPos = $db->prepare('SELECT COALESCE(MIN(position), 100) FROM event_photos WHERE event_id = ?');
|
||||||
|
$insPhoto = $db->prepare(
|
||||||
|
'INSERT INTO event_photos (event_id, `key`, position, kind, thumb_key, mime)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($tkRows as $tk) {
|
||||||
|
$eid = (int)$tk['event_id'];
|
||||||
|
$key = (string)$tk['ticket_key'];
|
||||||
|
$hasPhoto->execute([$eid, $key]);
|
||||||
|
if ((int)$hasPhoto->fetchColumn() > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$minPos->execute([$eid]);
|
||||||
|
$pos = max(0, (int)$minPos->fetchColumn() - 10);
|
||||||
|
$insPhoto->execute([$eid, $key, $pos, 'image', '', 'image/jpeg']);
|
||||||
|
}
|
||||||
|
/* 搬完清空旧槽位 —— 「只分两类」必须在数据层成立,
|
||||||
|
否则下一版又会有人写"票根另算"的特判。 */
|
||||||
|
$db->exec("UPDATE movie_details SET ticket_key = NULL
|
||||||
|
WHERE ticket_key IS NOT NULL AND ticket_key <> ''");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 纪念日(版本 13,用户第 13 条)----
|
// ---- 纪念日(版本 13,用户第 13 条)----
|
||||||
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||||
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||||
|
|||||||
@@ -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 = '';
|
$canonicalHeaders = '';
|
||||||
if ((string)($cred['token'] ?? '') !== '') {
|
if ((string)($cred['token'] ?? '') !== '') {
|
||||||
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
|
$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"
|
$stringToSign = $method . "\n"
|
||||||
. "\n" // Content-MD5(未使用)
|
. "\n" // Content-MD5(未使用)
|
||||||
. $contentType . "\n"
|
. $contentType . "\n"
|
||||||
@@ -200,12 +206,13 @@ final class OssDriver implements StorageDriver
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读请求刻意不发 Content-Type,签名串里传空串 */
|
/** 读请求刻意不发 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');
|
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||||
$h = [
|
$h = [
|
||||||
'Date: ' . $date,
|
'Date: ' . $date,
|
||||||
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred, $subresource),
|
||||||
];
|
];
|
||||||
if ((string)($cred['token'] ?? '') !== '') {
|
if ((string)($cred['token'] ?? '') !== '') {
|
||||||
$h[] = 'x-oss-security-token: ' . $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()) {
|
if ($err = $this->validateConfig()) {
|
||||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||||||
@@ -336,8 +355,14 @@ final class OssDriver implements StorageDriver
|
|||||||
if (empty($cred['ok'])) {
|
if (empty($cred['ok'])) {
|
||||||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||||||
}
|
}
|
||||||
$key = $this->objectKey($key);
|
$key = $this->objectKey($key);
|
||||||
$headers = $this->readHeaders($key, $cred, $extra);
|
|
||||||
|
/* 子资源串:签名与 URL 必须**用同一个值**,否则签名对不上 */
|
||||||
|
$sub = '';
|
||||||
|
if ($process !== '') {
|
||||||
|
$sub = '?x-oss-process=' . $process;
|
||||||
|
}
|
||||||
|
$headers = $this->readHeaders($key, $cred, $extra, 'GET', $sub);
|
||||||
|
|
||||||
$hosts = [];
|
$hosts = [];
|
||||||
$internal = $this->internalEndpoint();
|
$internal = $this->internalEndpoint();
|
||||||
@@ -349,8 +374,10 @@ final class OssDriver implements StorageDriver
|
|||||||
$res = null;
|
$res = null;
|
||||||
foreach ($hosts as $i => $host) {
|
foreach ($hosts as $i => $host) {
|
||||||
$isInternal = ($internal !== '' && $host === $internal);
|
$isInternal = ($internal !== '' && $host === $internal);
|
||||||
|
/* ⚠️ process 里的逗号、下划线不需要 URL 编码(OSS 认原样),
|
||||||
|
但 `?` 与 `=` 必须保留为分隔符 —— 所以直接拼,不 encode。 */
|
||||||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
$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);
|
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
|
||||||
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
|
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
|
||||||
continue; // 内网连不上,换外网
|
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, '/');
|
/* 只有无 process 时才走"本地优先":原图有缓存就直接读,
|
||||||
if ($key !== '' && is_file($local)) {
|
省掉整个 OSS round-trip。带 process 的必须去 OSS 要压缩版。 */
|
||||||
$body = (string)@file_get_contents($local);
|
if ($process === '') {
|
||||||
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local'];
|
$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
|
public static function exists(int $familyId, string $key): bool
|
||||||
|
|||||||
+112
@@ -557,12 +557,22 @@ final class Task
|
|||||||
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
|
// 抛 PDOException: Unknown column 'p.name',而它又被 PHP 以
|
||||||
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。
|
// "200 + Fatal error 正文"吐出来,看着像页面正常返回。
|
||||||
// 改 SQL 之后一定要用「真实库表结构」校验列名(见 sql-schema-column-check)。
|
// 改 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,
|
'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,
|
a.name AS assignee_name, a.avatar AS assignee_avatar, a.avatar_color AS assignee_color,
|
||||||
na.nickname AS assignee_nick,
|
na.nickname AS assignee_nick,
|
||||||
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
|
cr.name AS creator_name, cr.avatar AS creator_avatar, cr.avatar_color AS creator_color,
|
||||||
nc.nickname AS creator_nick
|
nc.nickname AS creator_nick
|
||||||
FROM tasks t
|
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 tasks p ON p.id = t.parent_id
|
||||||
LEFT JOIN persons a ON a.id = t.assignee_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 = ?
|
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)
|
* @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
|
public static function setStatus(int $familyId, int $id, string $status, int $position = 0): int
|
||||||
{
|
{
|
||||||
$old = self::row($familyId, $id);
|
$old = self::row($familyId, $id);
|
||||||
|
|||||||
+500
@@ -0,0 +1,500 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TaskDAV —— 待办(VTODO)走的 CalDAV 扩展层(多本模型)。
|
||||||
|
*
|
||||||
|
* 2026-11-02 新增。用户原话:
|
||||||
|
* 「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
|
||||||
|
*
|
||||||
|
* 2026-11-02 重构(第二版):
|
||||||
|
* 用户给了参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9),
|
||||||
|
* 并指出「应该可以直接显示那个家庭的待办分类」。
|
||||||
|
* 参照实测:待办按分类拆成多个独立 calendar 集合,各自 supported-calendar-component-set
|
||||||
|
* 只含 VTODO。客户端里显示成多个并列的待办列表。
|
||||||
|
* ⇒ 本版从「一个家庭待办本」改为「每个 task_categories 一行 = 一个 VTODO 本」,
|
||||||
|
* 外加一个 Inbox 本收未分类任务(与参照的 home_todo 同构)。
|
||||||
|
*
|
||||||
|
* ## 本的组织(用户已定)
|
||||||
|
*
|
||||||
|
* /dav/tasks-<categoryId>.ics/ 每个分类一本,名字就是分类名
|
||||||
|
* /dav/tasks-inbox.ics/ 未分类(category_id 为空)的任务
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么用 `tasks-<id>` 而不是裸 id:
|
||||||
|
* 真实日历本是 `/dav/<id>.ics/`,用裸 id 会与它们撞号
|
||||||
|
* (calendar_events 的 id 与 task_categories 的 id 是两套独立序列)。
|
||||||
|
* 加 `tasks-` 前缀后永远不会冲突,也不需要负数那种 trick。
|
||||||
|
*
|
||||||
|
* ## 权限口径
|
||||||
|
*
|
||||||
|
* 与网页端「任务」模块完全一致(User 可见 = Task::visibleTo)。
|
||||||
|
* 不在 DAV 层另写一套权限判断,否则"网页上不能改的,手机能改"会出问题。
|
||||||
|
*
|
||||||
|
* ## UID 方案
|
||||||
|
*
|
||||||
|
* `task-<id>`。前缀 `task-` 与日历的 UID 空间隔离(日历 UID 可能是
|
||||||
|
* `syncal-...`、`movie-100_familybook`,或客户端自造的任意串)。
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class TaskDav
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 本 href 前缀:`/dav/tasks-<id>.ics/`。
|
||||||
|
*
|
||||||
|
* Inbox 本用 `tasks-inbox.ics`(id 段是字符串 `inbox` 而不是数字)。
|
||||||
|
*/
|
||||||
|
public const HREF_PREFIX = '/dav/tasks-';
|
||||||
|
|
||||||
|
/** Inbox(未分类)本的 id 段 */
|
||||||
|
public const INBOX_SEG = 'inbox';
|
||||||
|
|
||||||
|
/** Inbox 本的显示名(与参照服务器的 home_todo 一个意思) */
|
||||||
|
public const INBOX_NAME = '收件箱';
|
||||||
|
|
||||||
|
/** 资源名前缀(与日历 UID 空间隔离) */
|
||||||
|
public const UID_PREFIX = 'task-';
|
||||||
|
|
||||||
|
/** ctag 版本,改了导出格式就 +1,让客户端重拉 */
|
||||||
|
private const CTAG_VERSION = 2;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 本的识别
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出「这个人能看到的待办本」。
|
||||||
|
*
|
||||||
|
* @return array<int,array{seg:string, id:int, name:string, href:string, color:string}>
|
||||||
|
* id 为 -1 表示 Inbox
|
||||||
|
*/
|
||||||
|
public static function books(int $familyId, int $personId): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
// ---- 各分类本 ----
|
||||||
|
/* ⚠️ 不直接用 Task::categories():它带 used_count 子查询,
|
||||||
|
对 DAV 每次发现请求都算一遍是浪费。这里只要 id/name/position。 */
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT id, name FROM task_categories WHERE family_id = ? ORDER BY position, id'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
foreach ($st->fetchAll() as $c) {
|
||||||
|
$cid = (int)$c['id'];
|
||||||
|
$out[] = [
|
||||||
|
'seg' => (string)$cid,
|
||||||
|
'id' => $cid,
|
||||||
|
'name' => (string)$c['name'],
|
||||||
|
'href' => self::HREF_PREFIX . $cid . '.ics/',
|
||||||
|
'color' => self::colorOf($cid, (string)$c['name']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inbox(未分类)----
|
||||||
|
/* ⚠️ 即使用户当前没有未分类任务,也要列出来 ——
|
||||||
|
否则客户端建不出"没选分类"的待办,且新任务会无处可去。
|
||||||
|
参照服务器也是恒定有 home_todo/Inbox 的。 */
|
||||||
|
$out[] = [
|
||||||
|
'seg' => self::INBOX_SEG,
|
||||||
|
'id' => -1,
|
||||||
|
'name' => self::INBOX_NAME,
|
||||||
|
'href' => self::HREF_PREFIX . self::INBOX_SEG . '.ics/',
|
||||||
|
'color' => '#8A8A8A',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条任务的**有效分类**(决定它出现在哪个本里)。
|
||||||
|
*
|
||||||
|
* 2026-11-02 新增。用户问「任务的子任务能否同步给客户端」,
|
||||||
|
* 实测发现父子跨本(父分类=1,子分类=NULL)⇒ 客户端无法显示层级。
|
||||||
|
* 用户已定:**子任务跟随父的分类**。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* · 自己有分类 → 用自己的(尊重用户明确设定)
|
||||||
|
* · 自己无分类 + 有父 → 跟父的分类(保证父子同本,层级才可显示)
|
||||||
|
* · 都没有 → 收件箱(-1)
|
||||||
|
*
|
||||||
|
* ⚠️ **不改数据库**:这是视图层算出来的。改库就等于篡改用户数据,
|
||||||
|
* 而且单向不可逆(以后想让子任务独立分类就回不去了)。
|
||||||
|
*
|
||||||
|
* @param array $row tasks 行(可用 `parent_cat_id` 预联查好的父分类)
|
||||||
|
*/
|
||||||
|
public static function effectiveCat(array $row): int
|
||||||
|
{
|
||||||
|
$own = (int)($row['category_id'] ?? 0);
|
||||||
|
if ($own > 0) {
|
||||||
|
return $own;
|
||||||
|
}
|
||||||
|
$pid = (int)($row['parent_id'] ?? 0);
|
||||||
|
if ($pid > 0) {
|
||||||
|
// 优先用预联查的父分类(避免 N+1)
|
||||||
|
if (array_key_exists('parent_cat_id', $row)) {
|
||||||
|
$pc = (int)($row['parent_cat_id'] ?? 0);
|
||||||
|
return $pc > 0 ? $pc : -1;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT category_id FROM tasks WHERE id = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$pid]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
$pc = $v === false || $v === null ? 0 : (int)$v;
|
||||||
|
return $pc > 0 ? $pc : -1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从一个 href 或路径解析出待办本的段(`<id>` 或 `inbox`)。
|
||||||
|
*
|
||||||
|
* @return array{seg:string, sub:string}|null
|
||||||
|
* seg = 本段(如 `3` 或 `inbox`),sub = 资源段(如 `task-12`),
|
||||||
|
* 不是待办本则返回 null。
|
||||||
|
*/
|
||||||
|
public static function parse(string $href): ?array
|
||||||
|
{
|
||||||
|
$p = rtrim((string)parse_url($href, PHP_URL_PATH), '/');
|
||||||
|
$prefix = rtrim(self::HREF_PREFIX, '/'); // /dav/tasks-
|
||||||
|
if (strpos($p, $prefix) !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$rest = substr($p, strlen($prefix)); // 如 `3.ics` 或 `3.ics/task-12.ics`
|
||||||
|
if ($rest === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$parts = explode('/', $rest);
|
||||||
|
$bookSeg = (string)preg_replace('/\.ics$/', '', $parts[0]);
|
||||||
|
if ($bookSeg === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$sub = '';
|
||||||
|
if (isset($parts[1]) && $parts[1] !== '') {
|
||||||
|
$sub = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
||||||
|
}
|
||||||
|
return ['seg' => $bookSeg, 'sub' => $sub];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个路径是不是待办本(含子资源) */
|
||||||
|
public static function isTaskPath(string $href): bool
|
||||||
|
{
|
||||||
|
return self::parse($href) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 段 → 分类 id(Inbox 返回 -1;认不出返回 -2 表示非法) */
|
||||||
|
public static function categoryIdOfSeg(string $seg): int
|
||||||
|
{
|
||||||
|
if ($seg === self::INBOX_SEG) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (preg_match('/^\d+$/', $seg) === 1) {
|
||||||
|
return (int)$seg;
|
||||||
|
}
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验这个段确实是**这个人能访问的**待办本。
|
||||||
|
*
|
||||||
|
* ⚠️ 必须校验:否则改一下 URL(`/dav/tasks-999.ics/`)就能探到
|
||||||
|
* 别的家庭的分类名,属于越权。
|
||||||
|
*/
|
||||||
|
public static function resolveBook(int $familyId, int $personId, string $seg): ?array
|
||||||
|
{
|
||||||
|
foreach (self::books($familyId, $personId) as $b) {
|
||||||
|
if ($b['seg'] === $seg) {
|
||||||
|
return $b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本名配色(稳定:同一个分类每次都是同一色) */
|
||||||
|
private static function colorOf(int $id, string $name): string
|
||||||
|
{
|
||||||
|
$palette = ['#B0763A', '#7A4E8C', '#3A7AB0', '#4E8C5A', '#B05A5A', '#8C7A4E', '#5A6B8C'];
|
||||||
|
return $palette[$id % count($palette)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 资源名
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条 tasks 行 → 资源名(href 最后一段,不含 .ics)。
|
||||||
|
*
|
||||||
|
* ⚠️ 与 CalDav::segmentOf() 同口径:一律用 UID。
|
||||||
|
* 客户端 PUT 到 `/dav/tasks-3.ics/task-5.ics` 后必须能按同名 GET 回来,
|
||||||
|
* 否则会表现成"写进去了又没了"(CalDav 那边踩过)。
|
||||||
|
*/
|
||||||
|
public static function segmentOf(array $t): string
|
||||||
|
{
|
||||||
|
return self::UID_PREFIX . (int)($t['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从资源段反解任务 id(`task-5` → 5;认不出返回 0) */
|
||||||
|
public static function idOfSegment(string $seg): int
|
||||||
|
{
|
||||||
|
$seg = trim($seg);
|
||||||
|
if ($seg === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (preg_match('/^' . preg_quote(self::UID_PREFIX, '/') . '(\d+)/', $seg, $m)) {
|
||||||
|
return (int)$m[1];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 读取
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某一本里的任务。
|
||||||
|
*
|
||||||
|
* @param int $catId 分类 id;-1 表示 Inbox(未分类)
|
||||||
|
*/
|
||||||
|
public static function tasksIn(int $familyId, int $personId, int $catId): array
|
||||||
|
{
|
||||||
|
/* ⚠️ 不能再用 SQL 直接按 `t.category_id` 过滤:
|
||||||
|
子任务要**跟随父的分类**(见 effectiveCat),
|
||||||
|
父分类在另一张行上,必须联查出来再在内存里判。
|
||||||
|
`parent_cat_id` 一次联查解决,避免逐条查父任务(N+1)。 */
|
||||||
|
$sql = 'SELECT t.*, b.name AS board_name, c.name AS cat_name,
|
||||||
|
p.category_id AS parent_cat_id,
|
||||||
|
pc.name AS parent_cat_name,
|
||||||
|
p.title AS parent_title
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_boards b ON b.id = t.board_id
|
||||||
|
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||||
|
LEFT JOIN tasks p ON p.id = t.parent_id
|
||||||
|
LEFT JOIN task_categories pc ON pc.id = p.category_id
|
||||||
|
WHERE t.family_id = ?
|
||||||
|
ORDER BY t.status <> \'done\', t.due_at IS NULL, t.due_at, t.position, t.id';
|
||||||
|
|
||||||
|
$st = Database::get()->prepare($sql);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
// 先按"有效分类"判它属不属于这一本
|
||||||
|
if (self::effectiveCat($r) !== $catId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Task::visibleTo($r, $personId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
|
||||||
|
/* ⚠️ 跨本父任务的提示:子任务跟自己的分类、而父在别的本时,
|
||||||
|
客户端会看到一条"有 RELATED-TO 但找不到那个父"的条目。
|
||||||
|
多给一个说明性字段,导出时写进 DESCRIPTION 尾部,
|
||||||
|
让用户至少知道"它的父在别的分类里",而不是以为数据坏了。 */
|
||||||
|
$r['parent_in_other_book'] = self::isParentInOtherBook($r);
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这条任务的父是否在**别的本**里(跨本父子)。
|
||||||
|
*
|
||||||
|
* 用于导出时加一句提示。只在"子任务自己有分类、父分类不同"时成立 ——
|
||||||
|
* 那种情况是用户明确给子任务设了别的分类,我们尊重它,但要让客户端可见。
|
||||||
|
*/
|
||||||
|
private static function isParentInOtherBook(array $r): bool
|
||||||
|
{
|
||||||
|
$pid = (int)($r['parent_id'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$own = (int)($r['category_id'] ?? 0);
|
||||||
|
if ($own <= 0) {
|
||||||
|
return false; // 自己没分类 → 已跟随父,必定同本
|
||||||
|
}
|
||||||
|
$pc = array_key_exists('parent_cat_id', $r)
|
||||||
|
? (int)($r['parent_cat_id'] ?? 0)
|
||||||
|
: 0;
|
||||||
|
$pcNorm = $pc > 0 ? $pc : -1;
|
||||||
|
return $pcNorm !== $own;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条任务(含权限)。取不到或无权看 → null。 */
|
||||||
|
public static function one(int $familyId, int $personId, int $taskId): ?array
|
||||||
|
{
|
||||||
|
if ($taskId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT t.*, b.name AS board_name, c.name AS cat_name
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_boards b ON b.id = t.board_id
|
||||||
|
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||||
|
WHERE t.id = ? AND t.family_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$taskId, $familyId]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
if (!$r) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Task::visibleTo($r, $personId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$r['uid'] = self::UID_PREFIX . (int)$r['id'];
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 导出
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条任务 → 资源(ics 正文 + ETag)。
|
||||||
|
*
|
||||||
|
* ⚠️ ETag 由内容哈希算出,不用"当前时间"。
|
||||||
|
* CalDav 那边为 ETag 潮汐踩过坑(客户端每轮全量重拉),这里从一开始就用正确做法。
|
||||||
|
*/
|
||||||
|
public static function resourceOf(array $t, string $calName = '待办'): array
|
||||||
|
{
|
||||||
|
$ics = Ics::exportTodos([$t], $calName);
|
||||||
|
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整本导出 */
|
||||||
|
public static function ics(int $familyId, int $personId, int $catId, string $calName): string
|
||||||
|
{
|
||||||
|
return Ics::exportTodos(self::tasksIn($familyId, $personId, $catId), $calName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ctag:这一本"变没变"的指纹。
|
||||||
|
*
|
||||||
|
* ⚠️ 必须覆盖任何会影响导出的变化,且不能随时间变。
|
||||||
|
* 用 COUNT + MAX(updated_at) + SUM(done_at) 三个一起,
|
||||||
|
* 单用 MAX(updated_at) 在同一秒改两条时会漏。
|
||||||
|
*/
|
||||||
|
public static function ctag(int $familyId, int $personId, int $catId): string
|
||||||
|
{
|
||||||
|
$sql = 'SELECT COUNT(*) AS n,
|
||||||
|
COALESCE(MAX(updated_at), \'\') AS mx,
|
||||||
|
COALESCE(SUM(UNIX_TIMESTAMP(COALESCE(done_at, \'1970-01-01\'))), 0) AS dn
|
||||||
|
FROM tasks WHERE family_id = ?';
|
||||||
|
$params = [$familyId];
|
||||||
|
if ($catId === -1) {
|
||||||
|
$sql .= ' AND category_id IS NULL';
|
||||||
|
} else {
|
||||||
|
$sql .= ' AND category_id = ?';
|
||||||
|
$params[] = $catId;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare($sql);
|
||||||
|
$st->execute($params);
|
||||||
|
$r = $st->fetch() ?: [];
|
||||||
|
|
||||||
|
$seed = self::CTAG_VERSION . '|' . $catId . '|'
|
||||||
|
. (int)($r['n'] ?? 0) . '|'
|
||||||
|
. (string)($r['mx'] ?? '') . '|'
|
||||||
|
. (int)($r['dn'] ?? 0);
|
||||||
|
return '"' . md5($seed) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 写入
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端 PUT 一条待办 → 落库。
|
||||||
|
*
|
||||||
|
* @param int $catId 本对应的分类 id(-1 = Inbox)
|
||||||
|
* @param int $taskId URL 段解析出的 id(0 = 新建)
|
||||||
|
* @param string $body text/calendar 正文
|
||||||
|
*
|
||||||
|
* @return array{id:int, created:bool}
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public static function put(int $familyId, int $personId, int $catId,
|
||||||
|
int $taskId, string $body): array
|
||||||
|
{
|
||||||
|
$parsed = Ics::parseTodos($body);
|
||||||
|
if (!$parsed) {
|
||||||
|
throw new \RuntimeException('正文里没有找到 VTODO。');
|
||||||
|
}
|
||||||
|
$n = Ics::normalizeTodo($parsed[0]);
|
||||||
|
|
||||||
|
if ($n['summary'] === '') {
|
||||||
|
throw new \RuntimeException('VTODO 缺少 SUMMARY(任务标题),拒绝写入。');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只有"确实存在且有权访问"时才走更新分支。
|
||||||
|
否则(客户端 PUT 到 task-99999.ics 而它不存在)必须**新建** ——
|
||||||
|
RFC 4918 §9.7 规定 PUT 到不存在的 URI 就是创建资源。
|
||||||
|
这也兼容"客户端用自己编的 UID 当资源名"的场景。 */
|
||||||
|
$old = $taskId > 0 ? self::one($familyId, $personId, $taskId) : null;
|
||||||
|
|
||||||
|
if ($old !== null) {
|
||||||
|
if (!Task::canWrite($old, $personId)) {
|
||||||
|
throw new \RuntimeException('这条任务你没有写权限。');
|
||||||
|
}
|
||||||
|
/* ⚠️ Task::update() 会把没传的字段清空(它按 `$d['x'] ?? ''` 取值),
|
||||||
|
所以必须把原值补全再传 —— 否则客户端只改标题就会把备注/截止时间清掉。
|
||||||
|
这是"部分更新"最容易出的 bug。 */
|
||||||
|
Task::update($familyId, $taskId, [
|
||||||
|
'title' => $n['summary'],
|
||||||
|
'note' => $n['description'],
|
||||||
|
'status' => $n['status'],
|
||||||
|
'due_at' => $n['due'] ?? '',
|
||||||
|
'assignee_id' => (int)($old['assignee_id'] ?? 0),
|
||||||
|
'repeat_rule' => (string)($old['repeat_rule'] ?? ''),
|
||||||
|
'parent_id' => (int)($old['parent_id'] ?? 0),
|
||||||
|
'board_id' => (int)($old['board_id'] ?? 0),
|
||||||
|
// 分类跟随**本**:客户端把资源放进了哪一本,就归哪个分类
|
||||||
|
'category_id' => $catId === -1 ? 0 : $catId,
|
||||||
|
]);
|
||||||
|
self::fixDoneAt($familyId, $taskId, $n);
|
||||||
|
return ['id' => $taskId, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 新建 ----
|
||||||
|
$id = Task::create($familyId, $personId, [
|
||||||
|
'title' => $n['summary'],
|
||||||
|
'note' => $n['description'],
|
||||||
|
'status' => $n['status'],
|
||||||
|
'due_at' => $n['due'] ?? '',
|
||||||
|
'category_id' => $catId === -1 ? 0 : $catId,
|
||||||
|
]);
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new \RuntimeException('创建任务失败。');
|
||||||
|
}
|
||||||
|
self::fixDoneAt($familyId, $id, $n);
|
||||||
|
return ['id' => $id, 'created' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用客户端给的 COMPLETED 时间覆盖 done_at。
|
||||||
|
*
|
||||||
|
* `Task::update()` 走到 done 时会自动 markDone(用"服务器当前时间"),
|
||||||
|
* 但客户端给的 COMPLETED 更准(它可能补录昨天完成的事)。
|
||||||
|
*/
|
||||||
|
private static function fixDoneAt(int $familyId, int $taskId, array $n): void
|
||||||
|
{
|
||||||
|
if ($n['status'] !== Task::DONE_STATUS || empty($n['completed'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Database::get()->prepare(
|
||||||
|
'UPDATE tasks SET done_at = ? WHERE id = ? AND family_id = ?'
|
||||||
|
)->execute([$n['completed'], $taskId, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户端 DELETE 一条待办 */
|
||||||
|
public static function delete(int $familyId, int $personId, int $taskId): bool
|
||||||
|
{
|
||||||
|
$t = self::one($familyId, $personId, $taskId);
|
||||||
|
if (!$t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Task::canWrite($t, $personId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Task::destroy($familyId, $taskId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$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 条)。
|
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
|
||||||
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
|
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
|
||||||
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
|
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
|
||||||
@@ -923,18 +1066,54 @@ final class Trip
|
|||||||
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
|
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
|
||||||
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
|
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
|
||||||
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
|
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
|
||||||
$fromPlace = (string)($d['from_place'] ?? '');
|
/* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
|
||||||
$toPlace = (string)($d['to_place'] ?? '');
|
|
||||||
$fromAddr = trim((string)($d['from_addr'] ?? ''));
|
原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
|
||||||
$toAddr = trim((string)($d['to_addr'] ?? ''));
|
调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
|
||||||
$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);
|
· 端上编辑浮层(旧版只传 `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 内部全兜底)。
|
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
|
||||||
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
|
/* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
|
||||||
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
|
这没必要,直接城市就可以」)。
|
||||||
|
|
||||||
|
前端传来的可能是「山东省青岛市市南区」(高德的 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 === '') {
|
if ($fromCity === '') {
|
||||||
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
||||||
}
|
}
|
||||||
@@ -1464,6 +1643,32 @@ final class Trip
|
|||||||
return self::NODE_TYPES[$key][1] ?? '📌';
|
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
|
public static function modeWord(string $key): string
|
||||||
{
|
{
|
||||||
|
|||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
<?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'] . ')',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽一帧做封面(2026-11-06,观影照片支持视频)。
|
||||||
|
*
|
||||||
|
* 为什么要抽首帧:列表/网格里不可能为每个视频实时解码 —— 一屏 9 个视频,
|
||||||
|
* 端上要同时挂 9 个解码器,必然卡死。必须服务端抽一帧存成**普通图片**,
|
||||||
|
* 列表只显示这张图,点开才真播。
|
||||||
|
*
|
||||||
|
* ⚠️ 取第 **1 秒**而不是第 0 帧:
|
||||||
|
* 手机视频开头经常是黑场/对焦过程(`-ss 0` 抽出来是一片黑),
|
||||||
|
* 第 1 秒通常已经有内容了。
|
||||||
|
* ⚠️ **失败不阻断**(返回空 path):
|
||||||
|
* 抽帧失败不影响视频本身能用;端上对没有封面的视频显示一个通用图标即可。
|
||||||
|
* 让"没有封面"变成上传失败是最不能接受的取舍。
|
||||||
|
*
|
||||||
|
* @return array{ok:bool, path:string, error:string}
|
||||||
|
*/
|
||||||
|
public static function poster(string $src): array
|
||||||
|
{
|
||||||
|
if (!self::available()) {
|
||||||
|
return ['ok' => false, 'path' => '', 'error' => '服务器没有 ffmpeg'];
|
||||||
|
}
|
||||||
|
$dst = $src . '.poster.jpg';
|
||||||
|
/* 宽度 480 足够列表缩略(端上最大也就显示 100vp 左右)。
|
||||||
|
等比、-2 保证偶数。`-frames:v 1` 只出这一帧。 */
|
||||||
|
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
|
||||||
|
. ' -ss 1 -i ' . escapeshellarg($src)
|
||||||
|
. ' -frames:v 1 -vf ' . escapeshellarg("scale=480:-2")
|
||||||
|
. ' -q:v 4'
|
||||||
|
. ' ' . escapeshellarg($dst)
|
||||||
|
. ' 2>&1';
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$code = 1;
|
||||||
|
/* 抽帧很快,给 30 秒足够;超时按失败处理,不拖住上传 */
|
||||||
|
@exec('timeout 30 ' . $cmd, $out, $code);
|
||||||
|
|
||||||
|
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
|
||||||
|
@unlink($dst);
|
||||||
|
$msg = trim(implode(' ', array_slice($out, 0, 2)));
|
||||||
|
return ['ok' => false, 'path' => '', 'error' => '抽帧失败' . ($msg !== '' ? ':' . $msg : '')];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'path' => $dst, 'error' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -21,7 +21,7 @@ final class View
|
|||||||
|
|
||||||
$tplFile = $file;
|
$tplFile = $file;
|
||||||
$pageKey = $page ?: $tpl;
|
$pageKey = $page ?: $tpl;
|
||||||
$pageTitle = $data['title'] ?? '家书';
|
$pageTitle = $data['title'] ?? '岁月家';
|
||||||
$extraHead = $data['extraHead'] ?? '';
|
$extraHead = $data['extraHead'] ?? '';
|
||||||
|
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
@@ -67,7 +67,7 @@ final class View
|
|||||||
throw new \RuntimeException('模板不存在:' . $tpl);
|
throw new \RuntimeException('模板不存在:' . $tpl);
|
||||||
}
|
}
|
||||||
$tplFile = $file;
|
$tplFile = $file;
|
||||||
$pageTitle = $data['title'] ?? '家书';
|
$pageTitle = $data['title'] ?? '岁月家';
|
||||||
$extraHead = $data['extraHead'] ?? '';
|
$extraHead = $data['extraHead'] ?? '';
|
||||||
extract($data, EXTR_SKIP);
|
extract($data, EXTR_SKIP);
|
||||||
require dirname(__DIR__) . '/templates/layout-bare.php';
|
require dirname(__DIR__) . '/templates/layout-bare.php';
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ if (!defined('ICON_STORE')) {
|
|||||||
$configFile = FB_ROOT . '/config.php';
|
$configFile = FB_ROOT . '/config.php';
|
||||||
if (!is_file($configFile)) {
|
if (!is_file($configFile)) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo '<!doctype html><meta charset="utf-8"><title>家书 · 未配置</title>'
|
echo '<!doctype html><meta charset="utf-8"><title>岁月家 · 未配置</title>'
|
||||||
. '<div style="max-width:520px;margin:12vh auto;padding:28px 32px;'
|
. '<div style="max-width:520px;margin:12vh auto;padding:28px 32px;'
|
||||||
. 'background:#fff;border:1px solid rgba(47,36,30,.1);border-radius:16px;'
|
. 'background:#fff;border:1px solid rgba(47,36,30,.1);border-radius:16px;'
|
||||||
. 'font:15px/1.8 -apple-system,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;color:#2F241E">'
|
. 'font:15px/1.8 -apple-system,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;color:#2F241E">'
|
||||||
|
|||||||
+99
-2
@@ -88,7 +88,31 @@ function avatar_url(?string $key): string
|
|||||||
* 附件代理地址。
|
* 附件代理地址。
|
||||||
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
|
* @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);
|
$key = trim((string)$key);
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
@@ -98,7 +122,31 @@ function media_url(?string $key, array $extra = []): string
|
|||||||
if (preg_match('#^https?://#i', $key)) {
|
if (preg_match('#^https?://#i', $key)) {
|
||||||
return $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 额外要显示的行 [[标签, 值], …]
|
* @param array $extra 额外要显示的行 [[标签, 值], …]
|
||||||
* @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义)
|
* @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
|
function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string
|
||||||
{
|
{
|
||||||
$date = (string)($ev['date'] ?? '');
|
$date = (string)($ev['date'] ?? '');
|
||||||
@@ -398,6 +480,16 @@ function fb_entry_json(array $ev, string $detail = '', string $edit = '', array
|
|||||||
if ($when !== '') { $rows[] = ['时间', $when]; }
|
if ($when !== '') { $rows[] = ['时间', $when]; }
|
||||||
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
if ((string)($ev['calname'] ?? '') !== '') { $rows[] = ['日历本', (string)$ev['calname']]; }
|
||||||
if ((string)($ev['location'] ?? '') !== '') { $rows[] = ['地点', (string)$ev['location']]; }
|
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 条):「"状态确定"这个不要了,把"状态确定"换成地点。
|
/* 🔴 v44(用户 2026-10-12 第 3 条):「"状态确定"这个不要了,把"状态确定"换成地点。
|
||||||
当然,如果没有地点,这一行就不要显示了。」
|
当然,如果没有地点,这一行就不要显示了。」
|
||||||
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
|
⇒ **删掉「状态」那一行**(地点上一行已经加了,"换成地点"就是把状态让位给地点)。
|
||||||
@@ -541,6 +633,11 @@ function task_panel_json(int $taskId, string $msg = '', bool $ok = true): void
|
|||||||
'id' => $taskId,
|
'id' => $taskId,
|
||||||
'status' => (string)($t['status'] ?? ''),
|
'status' => (string)($t['status'] ?? ''),
|
||||||
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
'word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
||||||
|
/* 🔴 #79(用户 2026-11-05):「首页卡片没有刷新。」
|
||||||
|
前端 `syncRow()` 原来只同步状态徽标,不同步标题。
|
||||||
|
标题可以从面板输入框读,但那条路依赖 DOM 还在 ——
|
||||||
|
服务端直接回一份才是稳的(同一个函数给所有面板动作复用)。 */
|
||||||
|
'title' => (string)($t['title'] ?? ''),
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
+530
-4
@@ -1958,6 +1958,12 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
padding: 2px 12px; border-radius: 20px; color: var(--muted);
|
padding: 2px 12px; border-radius: 20px; color: var(--muted);
|
||||||
}
|
}
|
||||||
.empty-hint { color: var(--muted); font-size: 14px; margin: 0; }
|
.empty-hint { color: var(--muted); font-size: 14px; margin: 0; }
|
||||||
|
/* 🔴 工单 #20:首页「近期任务」的空态提示是**一直渲染、靠 hidden 切换**的
|
||||||
|
(服务端有任务时给它 `hidden`,前端把最后一条删掉后再显示出来)。
|
||||||
|
⚠️ 浏览器默认的 `[hidden]{display:none}` 优先级最低 —— 只要哪天有人给
|
||||||
|
`.empty-hint` 加上 `display:flex` 之类,这句提示就会在"有任务时"也冒出来。
|
||||||
|
所以在这里显式再声明一次(与上面第 601 行那条注释是同一个约定)。 */
|
||||||
|
.empty-hint[hidden] { display: none !important; }
|
||||||
.viewers-list { display: flex; flex-wrap: wrap; gap: 12px; }
|
.viewers-list { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
.viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; }
|
.viewers-list .viewer-tag { padding: 4px 12px 4px 6px; font-size: 14px; }
|
||||||
.viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; }
|
.viewers-list .viewer-tag .chip-av { width: 28px; height: 28px; }
|
||||||
@@ -2406,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;
|
max-width: 92vw; max-height: 100%; width: auto; height: auto;
|
||||||
object-fit: contain; border-radius: 8px;
|
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 {
|
.pv-pdf {
|
||||||
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
|
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
|
||||||
}
|
}
|
||||||
@@ -3523,6 +3541,61 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
|
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
|
||||||
.tg-block b { font-weight: 600; margin-right: 3px; }
|
.tg-block b { font-weight: 600; margin-right: 3px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
跨天日程的分段(v88 / 工单 #35)
|
||||||
|
============================================================
|
||||||
|
用户:「如果一个日程跨了三天,应该在日历本上怎么显示呢?在周视图和日视图,
|
||||||
|
应该怎么显示?现在显示的有问题。」
|
||||||
|
|
||||||
|
做法:一个跨天日程在**每一列各画一段**(`build_time_grid` 算好每段的
|
||||||
|
top/height),视觉上要让它们看起来是**一条**,而不是三个互不相干的块。
|
||||||
|
|
||||||
|
为什么不做跨列绝对定位层:`.tg-body` 左边有 56px 固定 gutter,
|
||||||
|
窄屏还会横向滚动(`.cal-tg-card{overflow:auto}` + 每列 min-width 92px),
|
||||||
|
`.tg-col` 又是 `overflow:hidden`。跨列层要自己算偏移、跟滚动跑、还不能被裁。
|
||||||
|
在列内画段则天然对齐,代价只是段间有 1px 列边框缝 —— 用下面的样式糊上。
|
||||||
|
|
||||||
|
三段的关系:
|
||||||
|
· start(起始段):上圆下平 —— 下方要接中间段
|
||||||
|
· mid (中间段):上下都平 —— 两头都接
|
||||||
|
· end (结束段):上平下圆 —— 上方接中间段
|
||||||
|
⚠️ 内侧不画 border:`.tg-block` 原本有 1px 白边,段与段之间会多出两条白线,
|
||||||
|
看着像断开了。这里按段位取消对应的那条边。
|
||||||
|
⚠️ 左右内缩量要比单天块**小**(单天是 left:2px / width:-4px):
|
||||||
|
跨天段要尽量贴满整列,缝隙才窄,连起来才像一条。模板已给 1%/98%。 */
|
||||||
|
.tg-block.is-seg-start {
|
||||||
|
border-top-left-radius: 4px; border-top-right-radius: 4px;
|
||||||
|
border-bottom-left-radius: 0; border-bottom-right-radius: 0;
|
||||||
|
border-bottom-color: transparent; /* 下边不画,与中间段连上 */
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.tg-block.is-seg-mid {
|
||||||
|
border-radius: 0;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
padding-top: 1px; padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.tg-block.is-seg-end {
|
||||||
|
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||||
|
border-bottom-left-radius: 4px; border-bottom-right-radius: 4px;
|
||||||
|
border-top-color: transparent; /* 上边不画,与中间段连上 */
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
/* 延续标记:这段不是从今天开始的,用一个小箭头代替开始时刻 */
|
||||||
|
.tg-cont {
|
||||||
|
display: inline-block; margin-right: 2px;
|
||||||
|
opacity: .75; font-size: 10px; line-height: 1;
|
||||||
|
}
|
||||||
|
/* 跨天段占满整列宽度,内边距跟着收一点,字才不会被挤 */
|
||||||
|
.tg-block[data-seg] {
|
||||||
|
padding-left: 4px; padding-right: 4px;
|
||||||
|
/* 🔴 v88:跨天段用 left:0 / width:100%(模板按段区分),
|
||||||
|
必须配 box-sizing:border-box,否则 padding 会把宽度撑出列外。
|
||||||
|
`.tg-block` 已有 box-sizing:border-box,这里显式再写一次是为了防止
|
||||||
|
以后有人给单天块改 box-sizing 时顺手把这条也改掉。 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
|
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
|
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
|
||||||
@@ -3888,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;
|
font-size: 11.5px; padding: 1px 8px; border-radius: 5px;
|
||||||
background: var(--line-2); color: var(--muted);
|
background: var(--line-2); color: var(--muted);
|
||||||
}
|
}
|
||||||
.node-route-line { font-size: 12.5px; color: var(--muted); }
|
/* 🔴 2026-11-05 重做(用户:「这个位都没对齐呀…直接用个标签,然后右边一个箭头,
|
||||||
.node-route-line .arrow { color: var(--faint); margin: 0 3px; }
|
到北京南站就得了,不用写从哪到哪」)。
|
||||||
|
|
||||||
|
原来只是一个普通 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-spent { font-size: 12.5px; color: var(--brand-ink); }
|
||||||
.node-member { display: inline-flex; align-items: center; gap: 4px; }
|
.node-member { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.node-role {
|
.node-role {
|
||||||
@@ -6793,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 + .tk-sec { border-top: 1px solid var(--line-2); padding-top: 14px; }
|
||||||
.tk-sec-h {
|
.tk-sec-h {
|
||||||
display: flex; align-items: center; gap: 6px;
|
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; }
|
.tk-sec-h .group-n { margin-left: 0; }
|
||||||
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
|
/* 「状态」「看板」这两段是"标签 + 一排可点的东西",横排。
|
||||||
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
|
⚠️ 用 flex-wrap:看板可能是七八个,窄屏要能折行而不是撑出面板。 */
|
||||||
.tk-sec-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: 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-sec-lb { flex: 0 0 34px; font-size: 12.5px; color: var(--faint); }
|
||||||
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
.tk-seg { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
|
/* 面板里的状态徽标比卡片上的大一档(这里是要点的按钮,不是标签) */
|
||||||
@@ -6943,3 +7231,241 @@ label.is-req,
|
|||||||
.dark .mv-row.is-req > .mv-lb {
|
.dark .mv-row.is-req > .mv-lb {
|
||||||
color: #F08A78 !important;
|
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%; }
|
||||||
|
}
|
||||||
|
|||||||
+440
-11
@@ -118,6 +118,193 @@
|
|||||||
* 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。
|
* 实测两者不是同时都有。取到哪级用哪级,取不到就留空(**绝不瞎编**)。
|
||||||
* @returns {string} 形如 "青岛市 市南区" / "青岛" / ''(都没有)
|
* @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) {
|
function readPlace(poi) {
|
||||||
var a = poi.addressComponent || null;
|
var a = poi.addressComponent || null;
|
||||||
var province = '', city = '', district = '';
|
var province = '', city = '', district = '';
|
||||||
@@ -147,6 +334,168 @@
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v69(工单 #32):自建候选面板。
|
||||||
|
*
|
||||||
|
* **为什么要自建**:`AMap.AutoComplete` 的内置下拉只渲染 `name`,
|
||||||
|
* 同名地点(多个「星巴克」、同一个商场的不同入口)在候选里长得一模一样,
|
||||||
|
* 用户没法判断该选哪个。要在地名后面补详细地址,就只能自己接管渲染。
|
||||||
|
*
|
||||||
|
* ⚠️ 定位用 `absolute` + 挂在 `document.body` 上,**不挂在输入框父节点里**:
|
||||||
|
* 1. 输入框常在 `.mv-row` 这种"标签 62px + 控件占满"的两列网格里,
|
||||||
|
* 多一个子项会把右边那列挤掉(本项目 v24 踩过同类排版坑);
|
||||||
|
* 2. 表单弹框多有 `overflow: hidden`,挂里面会被裁掉。
|
||||||
|
* 用 `position: fixed` + 实时读 `getBoundingClientRect()` 定位,
|
||||||
|
* 滚动/缩放时跟着走(见 `placePanel`)。
|
||||||
|
* @param {HTMLElement} input 那个地址输入框
|
||||||
|
* @param {Function} onPick 点中候选后的回调(收到高德的 poi 对象)
|
||||||
|
*/
|
||||||
|
function makePanel(input, onPick) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'fb-addr-panel';
|
||||||
|
el.setAttribute('data-fb-addr-panel', '1');
|
||||||
|
/* ⚠️ 样式内联而不写进 app.css:这份脚本是全站共用、按需加载的,
|
||||||
|
把样式焊在这里才不会有"某个页面忘了引那段 CSS"的漏。 */
|
||||||
|
el.style.cssText = [
|
||||||
|
'position:fixed', 'z-index:2147483000', 'display:none',
|
||||||
|
'margin:0', 'padding:4px 0', 'list-style:none',
|
||||||
|
'max-height:264px', 'overflow-y:auto',
|
||||||
|
'background:#fff', 'border-radius:10px',
|
||||||
|
'box-shadow:0 6px 24px rgba(0,0,0,.16), 0 1px 3px rgba(0,0,0,.10)',
|
||||||
|
'border:1px solid rgba(0,0,0,.08)',
|
||||||
|
'-webkit-overflow-scrolling:touch',
|
||||||
|
'font-size:14px', 'line-height:1.35'
|
||||||
|
].join(';');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
var panel = { el: el, input: input, onPick: onPick, tips: [] };
|
||||||
|
/* 滚动/缩放时重算位置 —— 用 capture 阶段接,任何滚动容器都能收到 */
|
||||||
|
window.addEventListener('scroll', function () { placePanel(panel); }, true);
|
||||||
|
window.addEventListener('resize', function () { placePanel(panel); });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把面板放到输入框正下方,宽度跟随输入框(不超出视口) */
|
||||||
|
function placePanel(panel) {
|
||||||
|
if (!panel.el || panel.el.style.display === 'none') { return; }
|
||||||
|
var r = panel.input.getBoundingClientRect();
|
||||||
|
/* ⚠️ 量不到尺寸(`0×0`)有两种可能:
|
||||||
|
① 输入框真的不在布局里(表单被隐藏/已从 DOM 摘掉)→ 该收起;
|
||||||
|
② **刚 append 完、样式还没算完**(同一 tick 内读 rect 常见于旧引擎),
|
||||||
|
或**无布局环境**(jsdom 做单元测试时 rect 恒为 0)。
|
||||||
|
原来的写法一律 `hidePanel` 收掉,于是②这种情况会出现
|
||||||
|
"候选明明查到了却一显示就消失"——**这不是理论风险,是实测踩到的**。
|
||||||
|
⇒ 改成:量不到就**先不定位**(保留默认位置/宽度),但**不收起**。
|
||||||
|
面板若真该消失,`hidePanel` 在别处(失焦、选出、输入过短)都会调。 */
|
||||||
|
if (r.width === 0 && r.height === 0) {
|
||||||
|
panel.el.style.minWidth = '240px';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
var w = Math.min(Math.max(r.width, 240), vw - 16);
|
||||||
|
var left = Math.min(Math.max(r.left, 8), vw - w - 8);
|
||||||
|
panel.el.style.width = w + 'px';
|
||||||
|
panel.el.style.left = left + 'px';
|
||||||
|
/* 下方空间不足就翻到输入框上方(手机上键盘弹起时尤其常见) */
|
||||||
|
var below = window.innerHeight - r.bottom;
|
||||||
|
var h = panel.el.offsetHeight || 0;
|
||||||
|
if (below < Math.min(h, 160) && r.top > below) {
|
||||||
|
panel.el.style.top = Math.max(8, r.top - h - 6) + 'px';
|
||||||
|
} else {
|
||||||
|
panel.el.style.top = (r.bottom + 6) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel(panel) {
|
||||||
|
if (!panel || !panel.el) { return; }
|
||||||
|
panel.el.style.display = 'none';
|
||||||
|
panel.el.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染候选:**每行 = 地名(深色,主)+ 省市区/详细地址(灰色,副)**。
|
||||||
|
*
|
||||||
|
* 🔴 这正是工单 #32 的诉求 —— 地址必须出现在下拉里,作为选择依据。
|
||||||
|
* 同名地点只有靠这一行灰字才区分得开。
|
||||||
|
*
|
||||||
|
* ⚠️ 高德的 `tips` 里不同条目字段并不齐(实测):
|
||||||
|
* · POI 类:`name` + `address` + `district`
|
||||||
|
* · 纯行政区划类:`name`(就是地名)+ `district`,**没有 address**
|
||||||
|
* ⇒ secondary 要把能拿到的都拼上,全空就只显示主标题(不能留一片空白)。
|
||||||
|
* ⚠️ 用 `textContent` 逐个填,**不拼 HTML 字符串**:
|
||||||
|
* 地点名是高德返回的、可能含 `<` `&`,拼 HTML 会破版甚至注入。
|
||||||
|
*/
|
||||||
|
function renderPanel(panel, tips, onPick) {
|
||||||
|
var el = panel.el;
|
||||||
|
el.innerHTML = '';
|
||||||
|
var list = [];
|
||||||
|
for (var i = 0; i < tips.length; i++) {
|
||||||
|
var t = tips[i];
|
||||||
|
if (!t) { continue; }
|
||||||
|
/* 只要"有名字"的;`location` 为空的纯提示行(高德有时给空壳)跳过 */
|
||||||
|
var nm = String(t.name || '').trim();
|
||||||
|
if (nm === '') { continue; }
|
||||||
|
list.push(t);
|
||||||
|
}
|
||||||
|
if (list.length === 0) { hidePanel(panel); return; }
|
||||||
|
|
||||||
|
list.forEach(function (t) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.style.cssText = [
|
||||||
|
'padding:9px 14px', 'cursor:pointer', 'display:block',
|
||||||
|
'border-bottom:1px solid rgba(0,0,0,.04)'
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
var main = document.createElement('div');
|
||||||
|
main.style.cssText = 'color:#1b1b1f;font-weight:500;word-break:break-all';
|
||||||
|
main.textContent = String(t.name || '').trim();
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
/* 副标题 = 省市区 + 详细地址。两者常常有包含关系
|
||||||
|
(`district` 是"山东省青岛市崂山区",`address` 是"香港东路 195 号"),
|
||||||
|
直接拼接不会重复;但若 address 已经以 district 开头则去重。 */
|
||||||
|
var place = readPlace(t);
|
||||||
|
var addr = String(t.address || '').trim();
|
||||||
|
var parts = [];
|
||||||
|
if (place !== '') { parts.push(place); }
|
||||||
|
if (addr !== '' && addr.indexOf(place) !== 0) { parts.push(addr); }
|
||||||
|
else if (addr !== '' && place === '') { parts.push(addr); }
|
||||||
|
var sub = parts.join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (sub !== '') {
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.style.cssText = 'color:#8a8a8e;font-size:12px;margin-top:2px;word-break:break-all';
|
||||||
|
desc.textContent = sub;
|
||||||
|
row.appendChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停/按下高亮(触屏没有 hover,所以两件都绑) */
|
||||||
|
row.addEventListener('mouseenter', function () { row.style.background = '#f5f5f7'; });
|
||||||
|
row.addEventListener('mouseleave', function () { row.style.background = 'transparent'; });
|
||||||
|
row.addEventListener('touchstart', function () { row.style.background = '#f5f5f7'; });
|
||||||
|
|
||||||
|
/* ⚠️ 用 `mousedown` + `preventDefault` 而不是 `click`:
|
||||||
|
输入框失焦会触发收起逻辑,`click` 可能来不及。
|
||||||
|
`preventDefault` 还能阻止输入框先失焦再复位造成的"闪一下"。 */
|
||||||
|
row.addEventListener('mousedown', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
hidePanel(panel);
|
||||||
|
onPick(t);
|
||||||
|
});
|
||||||
|
/* 触屏上 mousedown 也会触发,但为稳妥补一次 touchend(并挡掉随后的 300ms click 穿透) */
|
||||||
|
row.addEventListener('touchend', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
hidePanel(panel);
|
||||||
|
onPick(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
placePanel(panel); // 先显示再量高度,才能判断"下方够不够"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定一个地址输入框。
|
* 绑定一个地址输入框。
|
||||||
*
|
*
|
||||||
@@ -232,9 +581,19 @@
|
|||||||
var detail = input.getAttribute('data-addr-detail') === '1';
|
var detail = input.getAttribute('data-addr-detail') === '1';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var ac = new window.AMap.AutoComplete({ input: input });
|
/* 🔴🔴 v69(用户 2026-10-30,工单 #32):「输入一个简单地址、下拉显示详细地址时,
|
||||||
ac.on('select', function (e) {
|
不能只把地点告诉我,一定要把详细地址也放在后面。因为同一个地址可能有好几个,
|
||||||
var poi = (e && e.poi) ? e.poi : null;
|
我根本不知道应该选哪一个。」
|
||||||
|
⇒ **不再用 `AMap.AutoComplete` 的内置下拉**(它只画 `name`,我们无法控制),
|
||||||
|
改成"高德只当查询器、下拉自己画":
|
||||||
|
· 构造时**不传 `input`** ⇒ 高德不会往 DOM 里塞它自己的候选面板;
|
||||||
|
· 用户输入 → 防抖 → `ac.search(kw, cb)` → 拿 `result.tips`;
|
||||||
|
· 自己渲染每行「**地名 + 省市区 + 详细地址**」,地址在后、灰色小字。
|
||||||
|
⚠️ 选中后的全部既有行为(回填、`data-addr-detail`、框下提示、
|
||||||
|
`fb-addr-picked` 事件)**原样保留**,只是入口从高德的 `select`
|
||||||
|
事件换成我们自己那行候选的 click —— 见下面的 `pick()`。 */
|
||||||
|
var ac = new window.AMap.AutoComplete({});
|
||||||
|
var pick = function (poi) {
|
||||||
if (!poi) { return; }
|
if (!poi) { return; }
|
||||||
|
|
||||||
// 输入框里放**名字**(用户认的是名字,不是"一串门牌号")
|
// 输入框里放**名字**(用户认的是名字,不是"一串门牌号")
|
||||||
@@ -250,11 +609,31 @@
|
|||||||
`poi.name` 塞回输入框(实测,v68 第一版就因此白写)——
|
`poi.name` 塞回输入框(实测,v68 第一版就因此白写)——
|
||||||
同步写会被它覆盖,看起来像"详细地址没生效"。 */
|
同步写会被它覆盖,看起来像"详细地址没生效"。 */
|
||||||
if (detail) {
|
if (detail) {
|
||||||
var ad0 = String(poi.address || '').trim();
|
/* 🔴🔴 2026-11-03 修(用户:「这并不是详细地址,详细地址应该是
|
||||||
if (ad0 !== '' && name !== '') {
|
青岛市黄岛区珠江路1018号」):
|
||||||
var full = name + ' · ' + ad0;
|
|
||||||
input.value = full;
|
原来拼的是 `name · poi.address`,而高德的 `poi.address`
|
||||||
setTimeout(function () { input.value = full; }, 0);
|
**只到街道级**(「珠江路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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +646,21 @@
|
|||||||
// 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。
|
// 而"抹掉用户输入"是不可逆的(他点保存后就真没了)。
|
||||||
// ⇒ 空着才补;已经有内容就尊重用户的。
|
// ⇒ 空着才补;已经有内容就尊重用户的。
|
||||||
if (String(el.value || '').trim() !== '') { return; }
|
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);
|
var pl = readPlace(poi);
|
||||||
if (pl !== '') { el.value = pl; }
|
if (pl !== '') { el.value = pl; }
|
||||||
|
} else if (t === 'city') {
|
||||||
|
var co = cityOnly(poi);
|
||||||
|
if (co !== '') { el.value = co; }
|
||||||
} else if (t === 'address' || t === 'addr') {
|
} else if (t === 'address' || t === 'addr') {
|
||||||
var ad = String(poi.address || '').trim();
|
var ad = String(poi.address || '').trim();
|
||||||
if (ad !== '') { el.value = ad; }
|
if (ad !== '') { el.value = ad; }
|
||||||
@@ -290,8 +681,46 @@
|
|||||||
detail: { name: name, place: readPlace(poi), address: String(poi.address || '') }
|
detail: { name: name, place: readPlace(poi), address: String(poi.address || '') }
|
||||||
}));
|
}));
|
||||||
} catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ }
|
} catch (err) { /* 老浏览器没有 CustomEvent 构造器,忽略即可 */ }
|
||||||
});
|
};
|
||||||
/* ✅ 到这里才算**真的绑上了** —— v68 之前这行在最前面,绑不上也照样标记(见上)。 */
|
|
||||||
|
var panel = makePanel(input, pick);
|
||||||
|
var timer = -1;
|
||||||
|
|
||||||
|
/* 输入 → 防抖 260ms → 查候选。
|
||||||
|
⚠️ 260ms 是本项目其它联想(观影片名)同款节奏,别乱调:
|
||||||
|
太快会在中文输入法拼字过程中反复请求(白烧配额、候选跳来跳去)。 */
|
||||||
|
var onInput = function () {
|
||||||
|
if (timer >= 0) { clearTimeout(timer); }
|
||||||
|
var kw = String(input.value || '').trim();
|
||||||
|
/* 少于 2 个字不查 —— 一个字的候选既多又没用,还会把高德配额吃掉。
|
||||||
|
⚠️ 但要把面板收起来:用户已经把字删掉了,留着旧候选会误点。 */
|
||||||
|
if (kw.length < 2) {
|
||||||
|
if (timer >= 0) { clearTimeout(timer); timer = -1; }
|
||||||
|
hidePanel(panel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
timer = -1;
|
||||||
|
try {
|
||||||
|
ac.search(kw, function (status, result) {
|
||||||
|
if (status !== 'complete' || !result) { hidePanel(panel); return; }
|
||||||
|
var tips = result.tips || [];
|
||||||
|
renderPanel(panel, tips, pick);
|
||||||
|
});
|
||||||
|
} catch (e) { hidePanel(panel); }
|
||||||
|
}, 260);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', onInput);
|
||||||
|
/* 点别处收起。用 `mousedown` 而不是 `click`:
|
||||||
|
`click` 在「点候选行」时也会冒泡上来,会把候选先收掉导致点不中。 */
|
||||||
|
var onDocDown = function (ev) {
|
||||||
|
if (panel.el && panel.el.contains(ev.target)) { return; }
|
||||||
|
if (ev.target === input) { return; }
|
||||||
|
hidePanel(panel);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocDown);
|
||||||
|
|
||||||
input.setAttribute('data-addr-bound', '1');
|
input.setAttribute('data-addr-bound', '1');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。
|
/* SDK 已加载但初始化失败(key 没绑域名等)—— 静默降级、不打扰用户。
|
||||||
|
|||||||
+503
-2
@@ -90,14 +90,21 @@
|
|||||||
document.head.appendChild(l);
|
document.head.appendChild(l);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React
|
||||||
|
(Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。
|
||||||
|
详见 Semi 段 `loadJs` 上方的完整说明。 */
|
||||||
function loadJs(src) {
|
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');
|
var el = document.createElement('script');
|
||||||
el.src = src;
|
el.src = src;
|
||||||
el.onload = function () { res(); };
|
el.onload = function () { res(); };
|
||||||
el.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
el.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||||||
document.head.appendChild(el);
|
document.head.appendChild(el);
|
||||||
});
|
});
|
||||||
|
return reg[src];
|
||||||
}
|
}
|
||||||
function ensureCropper() {
|
function ensureCropper() {
|
||||||
if (window.Cropper) { return Promise.resolve(); }
|
if (window.Cropper) { return Promise.resolve(); }
|
||||||
@@ -1597,14 +1604,47 @@
|
|||||||
document.head.appendChild(l);
|
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) {
|
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');
|
var s = document.createElement('script');
|
||||||
s.src = src;
|
s.src = src;
|
||||||
s.onload = function () { res(); };
|
s.onload = function () { res(); };
|
||||||
s.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
s.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
|
return reg[src];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||||
@@ -2257,6 +2297,19 @@
|
|||||||
writeDT(box, s); // 写回隐藏四字段
|
writeDT(box, s); // 写回隐藏四字段
|
||||||
if (box.__fbSyncParts) { box.__fbSyncParts(); }
|
if (box.__fbSyncParts) { box.__fbSyncParts(); }
|
||||||
box.classList.toggle('has-value', !!s);
|
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();
|
var v0 = cur();
|
||||||
/* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。
|
/* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。
|
||||||
@@ -2580,3 +2633,451 @@
|
|||||||
if (!document.hidden) { place(); }
|
if (!document.hidden) { place(); }
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
首页「近期任务」:把一条已完成的任务行从卡片里摘掉(工单 #20)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
用户原话(2026-10-28,两次强调):「首页,近期任务,如果勾选了完成,
|
||||||
|
应该刷新一下这个列表,把完成的删掉。」
|
||||||
|
|
||||||
|
🔴🔴 为什么这段放在 app.js 而不是留在 home.php 里:
|
||||||
|
"把这条标成完成"在首页有**两个入口**——
|
||||||
|
① 行首那颗复选框(脚本在 home.php 末尾)
|
||||||
|
② 点整行弹浮层 → 面板里的「状态」三段按钮(脚本是 task_panel.js)
|
||||||
|
第一版只改了 ①,于是同一个用户动作(都是"标完成")两条路径两种结果:
|
||||||
|
从复选框勾 → 行消失;从面板点「已完成」→ 行留着,徽标变成「已完成」。
|
||||||
|
用户复现的就是第二条(我先前的判据完全没测浮层,所以 20/0 全绿也是假绿,
|
||||||
|
→ pitfalls「需求横跨几个入口就每个入口都要判据」)。
|
||||||
|
|
||||||
|
⚠️ 作用域必须限定 `[data-tk-home-host]`(首页任务卡片的外层):
|
||||||
|
任务**看板**页的行也带 `data-task-row`,那里勾完成是**只改状态、不删行**
|
||||||
|
(看板是按状态分栏的,"行"就是那个任务本身,删了就没处看了)。
|
||||||
|
所以这段绝不能按 `[data-task-row]` 全局找。
|
||||||
|
|
||||||
|
⚠️ 只在"变更后确实是 done"时删(判据用服务端回的 `d.status`,不是我算出来的 next):
|
||||||
|
`tasks/status` 是幂等接口,传 done 再来一次还是 done;拿返回值判断才不会
|
||||||
|
"手快连点两下,第二下把行删了"。
|
||||||
|
|
||||||
|
@param {Element} row 要摘掉的那一行 `li[data-task-row]`
|
||||||
|
@param {string} wasStatus 改**之前**的状态('doing' / 'todo')——
|
||||||
|
决定概述卡该减「件在做」还是「件待办」。
|
||||||
|
⚠️ 不能在这里读 `data-tk-status`:调用方通常已经把它改成 done 了。
|
||||||
|
@return {boolean} 真的删了才 true(行不在首页卡片里就 false,什么也不做)
|
||||||
|
============================================================================ */
|
||||||
|
window.fbHomeTaskDrop = function (row, wasStatus) {
|
||||||
|
if (!row) { return false; }
|
||||||
|
var host = row.closest ? row.closest('[data-tk-home-host]') : null;
|
||||||
|
/* 不在首页卡片里(任务看板 / 详情页子任务列表)→ 一律不删。
|
||||||
|
⚠️ 这条判断是这段代码能被两个入口共用的**前提**,别为了"通用"去掉它。 */
|
||||||
|
if (!host || !host.contains(row)) { return false; }
|
||||||
|
|
||||||
|
var ul = row.parentNode;
|
||||||
|
if (row.parentNode) { row.parentNode.removeChild(row); }
|
||||||
|
|
||||||
|
/* 概述卡的「N 件在做 / N 件待办」同步减 1。
|
||||||
|
⚠️ 用 `[data-hbs]` 定位(模板里专门加的钩子),**不靠中文文案** ——
|
||||||
|
"件在做"改一个字就失效,而这是 JS 要读的东西。 */
|
||||||
|
if (wasStatus === 'doing' || wasStatus === 'todo') {
|
||||||
|
var a = document.querySelector('.hb-stats a.hbs[data-hbs="' + wasStatus + '"]');
|
||||||
|
var b = a ? a.querySelector('b') : null;
|
||||||
|
if (b) {
|
||||||
|
var n = Math.max(0, (parseInt(b.textContent, 10) || 0) - 1);
|
||||||
|
b.textContent = String(n);
|
||||||
|
/* 减到 0 就把整条藏起来:服务端是 `if ($nTodo > 0)` 才渲染待办那一条,
|
||||||
|
前端不藏会出现「0 件待办」——服务端永远不会画出来的状态。 */
|
||||||
|
if (n === 0) { a.hidden = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删到一条不剩:摘掉空 ul,把服务端渲染的空态提示露出来
|
||||||
|
—— 否则卡片变成一片空白,用户以为加载坏了。 */
|
||||||
|
if (ul && ul.querySelectorAll && ul.querySelectorAll('[data-task-row]').length === 0) {
|
||||||
|
if (ul.parentNode) { ul.parentNode.removeChild(ul); }
|
||||||
|
var empty = host.querySelector('[data-tk-empty]');
|
||||||
|
if (empty) { empty.hidden = false; }
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
日程「就地更新」—— 新建 / 修改**不刷新页面**(工单 #37)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
用户原话(2026-11-01):
|
||||||
|
「日程又有了新bug,修改一个日程时,页面会刷新,这样如果我新建或者修改的日程
|
||||||
|
不是今天的,那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
|
||||||
|
修改和新建,都不应该刷新页面,而是使用js动态调整。」
|
||||||
|
|
||||||
|
---------------------------------------------------------------- 为什么这么写
|
||||||
|
历史上「新建不刷新」是靠**手写复刻服务端渲染**做的(`templates/calendar/index.php`
|
||||||
|
底部那段 `putMonth` / `putTimed` / `relayoutColumn`)。那段代码自己的注释里就写着:
|
||||||
|
· "插入用的定位方式必须与服务端渲染的完全一致,否则刷新一次小条就会跳位置"
|
||||||
|
· 一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒ 落进「+N 条」看不见,
|
||||||
|
可刷新一次又跑到第一位
|
||||||
|
修改要处理的情况比新建多得多(跨天变了 / 全天状态变了 / 换了日历本改了颜色 /
|
||||||
|
改了重复规则 / 从这天挪到了别月),复刻必然出错。
|
||||||
|
|
||||||
|
⇒ 本文件改用**服务端渲染片段 → 前端整块替换**:
|
||||||
|
改完 → 取一份服务端画好的视图 → 换掉容器里的内容。
|
||||||
|
只有一处渲染,"插进去的样子"与"刷新一次的样子"**不可能**不一致。
|
||||||
|
|
||||||
|
---------------------------------------------------------------- 三个落点
|
||||||
|
① `[data-cal-view]` 日历页的月/周/日视图(`templates/calendar/_view.php`)
|
||||||
|
② `#homeCard-agenda` 首页「今天的日程」卡片(`templates/home/_card_agenda.php`)
|
||||||
|
③ 「+N 条」清单的 `#calDayData` 数据岛(月视图那一份 JSON,换掉后清单才跟着变对)
|
||||||
|
|
||||||
|
---------------------------------------------------------------- 硬约束(都是踩过的)
|
||||||
|
· **只换内容、不换容器**:容器上挂着 `data-cal-view-kind` 之类的上下文,
|
||||||
|
换掉会让 CSS 的 `.cal-main > *` 重算(视觉会跳一下),而且监听会丢。
|
||||||
|
· **替换前记滚动位置**:`.hc-agenda [data-ag-scroll]` 进场会滚到"现在",
|
||||||
|
不恢复的话用户正看着的那条就被弹走了。
|
||||||
|
· **替换后要补跑三件事**:红线定时器(`data-now-line` 是 app.js 首屏扫的)、
|
||||||
|
「+N 条」数据岛、以及本文件自己的重绑。否则换进来的新节点是"死的"。
|
||||||
|
· **幂等**:脚本全站加载,被重复引入(AJAX 换正文、模板重复)不能绑两遍 ——
|
||||||
|
`window.__fbCalSyncBound` 闸门。
|
||||||
|
· **拿不到片段就退回整页刷新**:宁可刷一次,不要"没刷新却也没变"。
|
||||||
|
用户最怕的就是"不知道修改成功了没有",那种状态比刷新更糟。
|
||||||
|
============================================================================ */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (window.__fbCalSyncBound) { return; }
|
||||||
|
window.__fbCalSyncBound = true;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 工具 */
|
||||||
|
|
||||||
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
/** 当前页面是哪个视图:从容器上读(服务端渲染时就写好了)。 */
|
||||||
|
function viewKind() {
|
||||||
|
var el = document.querySelector('[data-cal-view]');
|
||||||
|
return el ? (el.getAttribute('data-cal-view-kind') || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼局部重绘要请求的地址。
|
||||||
|
*
|
||||||
|
* ⚠️ 参数**照抄当前页面的 URL**:用户在月视图看 11 月,就还取 11 月;
|
||||||
|
* 在周视图看某周,就还取那一周。绝不"按改动的那条日程重新算" ——
|
||||||
|
* 改一条别的月份的日程时,那会把用户正在看的那一屏换掉。
|
||||||
|
* 这条是用户报的现象的核心(改完刷新,跳到了别的月份/别的视图)。
|
||||||
|
*/
|
||||||
|
function viewUrl() {
|
||||||
|
var kind = viewKind();
|
||||||
|
if (!kind) { return ''; }
|
||||||
|
var q = new URLSearchParams();
|
||||||
|
q.set('r', 'calendar/view');
|
||||||
|
q.set('view', kind);
|
||||||
|
/* 上下文全部从**容器自己身上**读(服务端渲染时写的,准)。
|
||||||
|
不去猜隐藏域、也不从页头翻页链接里抠字符串 —— 那些在"翻过页"之后会对不上
|
||||||
|
(踩过:`#calJumpD` 带的是 anchor,翻到 12 月它还是 11 月那天的日期)。 */
|
||||||
|
var el = document.querySelector('[data-cal-view]');
|
||||||
|
var ym = el ? (el.getAttribute('data-cal-ym') || '') : '';
|
||||||
|
var anc = el ? (el.getAttribute('data-cal-anchor') || '') : '';
|
||||||
|
if (kind === 'week' || kind === 'day') {
|
||||||
|
if (anc) { q.set('d', anc); }
|
||||||
|
} else if (ym) {
|
||||||
|
q.set('ym', ym);
|
||||||
|
}
|
||||||
|
return 'index.php?' + q.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchHtml(url) {
|
||||||
|
return fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) { throw new Error('http ' + r.status); }
|
||||||
|
return r.text();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 重绘 */
|
||||||
|
|
||||||
|
/** 记录 / 恢复首页日程卡片的滚动位置(它进场会滚到"现在")。 */
|
||||||
|
function grabScroll() {
|
||||||
|
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
|
||||||
|
return box ? box.scrollTop : null;
|
||||||
|
}
|
||||||
|
function putScroll(top) {
|
||||||
|
if (top === null || top === undefined) { return; }
|
||||||
|
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]');
|
||||||
|
if (box) { box.scrollTop = top; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把新片段塞进容器:**只换子节点,不动容器本身**。
|
||||||
|
*
|
||||||
|
* 片段顶层是一个 `<div class="cal-view">`(或卡片的 `.hc-body`),
|
||||||
|
* 拿它的 innerHTML 填进现有容器即可 —— 这样容器上的属性/监听都还在。
|
||||||
|
*/
|
||||||
|
function swap(host, html) {
|
||||||
|
var tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
/* 片段顶层可能是 .cal-view(日历)或 .hc-body(卡片),两种都认 */
|
||||||
|
var src = tmp.querySelector('[data-cal-view], .hc-body') || tmp.firstElementChild;
|
||||||
|
if (!src) { return false; }
|
||||||
|
host.innerHTML = src.innerHTML;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重画「+N 条」用的数据岛(月视图)。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么必须换它:清单内容来自 `#calDayData` 那份 JSON。
|
||||||
|
* 改了一条之后如果不更新,点「+N 条」看到的还是改之前的样子 ——
|
||||||
|
* 这正是本项目 v70 记过的那类"清单与格子不一致"。
|
||||||
|
* 数据岛由服务端随视图片段一起产出(`_view.php` 里就在网格前面),
|
||||||
|
* 所以这里只要把新的那份整段搬过来。
|
||||||
|
*/
|
||||||
|
function syncDayData(html) {
|
||||||
|
var host = byId('calDayData');
|
||||||
|
if (!host) { return; }
|
||||||
|
var tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
var fresh = tmp.querySelector('#calDayData');
|
||||||
|
if (!fresh) { return; }
|
||||||
|
try { host.textContent = fresh.textContent || '{}'; } catch (e) { /* 失败就别动 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重绘后补跑那些"首屏扫过一次"的初始化。 */
|
||||||
|
function reinit() {
|
||||||
|
/* 红线:app.js 里那个 IIFE 只在首屏扫 `[data-now-line]` 并起定时器。
|
||||||
|
换进来的新节点没人管它 —— 不补的话线会停在服务端给的位置不动
|
||||||
|
(一小时后再看就明显错了)。这里就地算一次当前位置,并起一个新的定时器
|
||||||
|
(旧的会因为没有节点而空转,量极小;用标记避免重复起)。 */
|
||||||
|
var lines = document.querySelectorAll('[data-now-line]');
|
||||||
|
if (lines.length) {
|
||||||
|
var paint = function () {
|
||||||
|
var d = new Date();
|
||||||
|
var pct = ((d.getHours() * 60 + d.getMinutes()) / 1440) * 100;
|
||||||
|
Array.prototype.forEach.call(lines, function (el) {
|
||||||
|
el.style.top = pct.toFixed(4) + '%';
|
||||||
|
el.setAttribute('title',
|
||||||
|
'现在 ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
paint();
|
||||||
|
if (!window.__fbCalNowTimer) {
|
||||||
|
window.__fbCalNowTimer = setInterval(paint, 60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局部重绘入口。
|
||||||
|
*
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {string} [opts.msg] 成功提示(落在页面上的轻提示里)
|
||||||
|
* @param {boolean} [opts.hard] true = 强制走整页刷新(例如整条系列都变了)
|
||||||
|
* @return {Promise<boolean>} 重绘是否成功
|
||||||
|
*/
|
||||||
|
function refresh(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (opts.hard || !window.fetch) {
|
||||||
|
window.location.reload();
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCal = !!document.querySelector('[data-cal-view]');
|
||||||
|
var hasCard = !!(byId('homeCard-agenda') || document.querySelector('#homeCard-agenda'));
|
||||||
|
|
||||||
|
/* 两个落点都不在(比如在任务页、资源页):这条日程的改动没有可见的地方,
|
||||||
|
安静地成功即可 —— 不要为了"看不见的东西"刷一次页。 */
|
||||||
|
if (!hasCal && !hasCard) { return Promise.resolve(true); }
|
||||||
|
|
||||||
|
var jobs = [];
|
||||||
|
if (hasCal) {
|
||||||
|
var u = viewUrl();
|
||||||
|
if (!u) { window.location.reload(); return Promise.resolve(false); }
|
||||||
|
jobs.push(fetchHtml(u).then(function (html) {
|
||||||
|
var host = document.querySelector('[data-cal-view]');
|
||||||
|
if (!host || !swap(host, html)) { throw new Error('swap'); }
|
||||||
|
syncDayData(html);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (hasCard) {
|
||||||
|
jobs.push(fetchHtml('index.php?r=home/agenda').then(function (html) {
|
||||||
|
var host = document.querySelector('#homeCard-agenda');
|
||||||
|
/* 卡片的外层是 `<section id="homeCard-agenda">`,要换的是里面的 `.hc-body`。
|
||||||
|
当天的日程块本身就是 `.hc-body.hc-agenda` —— 拿它当替换目标。 */
|
||||||
|
var body = host ? host.querySelector('.hc-body') : null;
|
||||||
|
if (!body) { throw new Error('no body'); }
|
||||||
|
var top = grabScroll();
|
||||||
|
if (!swap(body, html)) { throw new Error('swap'); }
|
||||||
|
putScroll(top);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(jobs).then(function () {
|
||||||
|
reinit();
|
||||||
|
if (opts.msg) { toast(opts.msg); }
|
||||||
|
return true;
|
||||||
|
}).catch(function () {
|
||||||
|
/* 任何一步失败 → 退回整页刷新。宁可刷一次,也不要"没刷新却也没变"。 */
|
||||||
|
window.location.reload();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 轻提示 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面顶部一句轻提示。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不用 `flash`(服务端那条):flash 要等下一次整页渲染才出现,
|
||||||
|
* 而本工单的整个目的就是"不刷新"。没有提示的话,用户在原地看不到任何
|
||||||
|
* 变化时仍然会怀疑"到底存没存上"(他原话:"不知道修改成功了没有")。
|
||||||
|
*/
|
||||||
|
var toastTimer = null;
|
||||||
|
function toast(msg, isErr) {
|
||||||
|
/* ⚠️ 复用站内**已有的**那套浮动小条(`.tk-flash`,v55 为"任务状态改完给一句反馈"
|
||||||
|
加的那一个),不另发明一套 —— 两套视觉迟早不一致,深色模式还要各补一遍。
|
||||||
|
⚠️ 开关只认 `hidden` 属性(与 `templates/tasks/show.php` 同一个用法):
|
||||||
|
`[hidden]` 的 `display:none` 是浏览器默认样式、优先级最低,
|
||||||
|
CSS 里必须有一条 `.tk-flash[hidden]{display:none}` 兜着,
|
||||||
|
否则作者样式里的 `display` 会把它盖掉 ⇒ 提示永远收不起来。 */
|
||||||
|
var box = byId('fbCalToast');
|
||||||
|
if (!box) {
|
||||||
|
box = document.createElement('div');
|
||||||
|
box.id = 'fbCalToast';
|
||||||
|
box.className = 'tk-flash';
|
||||||
|
box.setAttribute('role', 'status');
|
||||||
|
box.setAttribute('aria-live', 'polite');
|
||||||
|
box.hidden = true;
|
||||||
|
document.body.appendChild(box);
|
||||||
|
}
|
||||||
|
box.textContent = msg;
|
||||||
|
box.className = 'tk-flash' + (isErr ? ' is-err' : '');
|
||||||
|
box.hidden = false;
|
||||||
|
if (toastTimer) { clearTimeout(toastTimer); }
|
||||||
|
toastTimer = setTimeout(function () {
|
||||||
|
box.hidden = true;
|
||||||
|
}, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 首页新建 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页那张「新建日程」表单**也要不刷新**。
|
||||||
|
*
|
||||||
|
* ⚠️ 只接管"首页那条路":判据是页面上**没有** `.cal-wrap`(那是日历模块页)。
|
||||||
|
* 日历页自己有完整的无刷新逻辑(模板底部那段),两边都绑会导致
|
||||||
|
* 提交两次(一次 fetch 一次原生)—— 本项目 `add_modal.js` 的注释里
|
||||||
|
* 记着同一类"绑两遍"事故。
|
||||||
|
*/
|
||||||
|
function bindHomeAddForm() {
|
||||||
|
if (document.querySelector('.cal-wrap')) { return; } /* 日历模块页:不接管 */
|
||||||
|
var form = byId('calAddForm');
|
||||||
|
if (!form || form.getAttribute('data-fb-cal-bound') === '1') { return; }
|
||||||
|
if (!window.fetch) { return; }
|
||||||
|
form.setAttribute('data-fb-cal-bound', '1');
|
||||||
|
|
||||||
|
var tip = byId('calAddTip');
|
||||||
|
|
||||||
|
function sayErr(msg) {
|
||||||
|
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
|
||||||
|
else { toast(msg, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (form.getAttribute('data-native-submit') === '1') { return; }
|
||||||
|
e.preventDefault();
|
||||||
|
var sub = byId('calAddSubmit');
|
||||||
|
if (sub) { sub.disabled = true; }
|
||||||
|
if (tip) { tip.textContent = ''; tip.className = 'cal-modal-tip'; }
|
||||||
|
|
||||||
|
fetch(form.getAttribute('action'), {
|
||||||
|
method: 'POST', body: new FormData(form), credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
|
||||||
|
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (sub) { sub.disabled = false; }
|
||||||
|
if (d && d.ok) {
|
||||||
|
/* 关浮层 → 清掉"只填一次"的字段(保留日历本选择,用户常连加几条) */
|
||||||
|
if (window.fbAddModal && form.closest('.cal-modal')) {
|
||||||
|
window.fbAddModal.close('#' + form.closest('.cal-modal').id);
|
||||||
|
} else {
|
||||||
|
var m = form.closest('.cal-modal');
|
||||||
|
if (m) { m.hidden = true; }
|
||||||
|
}
|
||||||
|
['ev-title', 'ev-location', 'ev-note'].forEach(function (id) {
|
||||||
|
var el = byId(id);
|
||||||
|
if (el) { el.value = ''; }
|
||||||
|
});
|
||||||
|
/* 观影/旅程本是派生的:服务端给了 goto,就别急着说"日程已加上" */
|
||||||
|
refresh({ msg: (d.msg || '日程已加上。') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d && d.msg) { sayErr(d.msg); return; }
|
||||||
|
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
|
||||||
|
form.setAttribute('data-native-submit', '1');
|
||||||
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
|
else { form.submit(); }
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (sub) { sub.disabled = false; }
|
||||||
|
form.setAttribute('data-native-submit', '1');
|
||||||
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
|
else { form.submit(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 导出 */
|
||||||
|
|
||||||
|
window.fbCalSync = {
|
||||||
|
refresh: refresh,
|
||||||
|
viewUrl: viewUrl,
|
||||||
|
viewKind: viewKind,
|
||||||
|
toast: toast
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', bindHomeAddForm);
|
||||||
|
} else {
|
||||||
|
bindHomeAddForm();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+145
-10
@@ -46,8 +46,12 @@
|
|||||||
(页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
|
(页面里还有"点某天新建日程"那套逻辑,它同样排除 `.cal-ev`)。
|
||||||
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
|
③ **数据全在 `data-fb-entry` 上**:浮层不发请求就能画出来,秒开。
|
||||||
④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
|
④ **详情/编辑都就地换正文**(用户:「就在当前的页面」)——不叠第二层浮层。
|
||||||
⑤ **保存走原生提交**:四个来源的 update 路由本来就收普通 POST 并跳转
|
⑤ **保存:日程走 AJAX,其余来源走原生提交**(工单 #37 起)。
|
||||||
(自己写 AJAX 保存要为四个来源各写一套"成功后回哪",得不偿失)。
|
· 日程(`#calEditForm`)→ fetch → 成功后原地重绘,**不刷新**。
|
||||||
|
服务端 `calendar/event_update` 认 X-Requested-With 回 JSON(新加的分支)。
|
||||||
|
· 名册 / 观影 / 行程 / 任务 → 仍是原生提交 + 302,行为与以前完全一致。
|
||||||
|
它们各自的 update 路由没有 JSON 出口,硬改要把四个模块一起动;
|
||||||
|
用户报的是日程,范围就收在日程上,不顺手扩大回归面。
|
||||||
⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
|
⑥ **删除走 AJAX**(v49 新增,服务端 `calendar/event_del` 认 X-Requested-With 回 JSON):
|
||||||
成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
|
成功后就地摘掉那一条,**不刷页** —— 与编辑不同,删除不需要"回到哪一页"的语义。
|
||||||
⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
|
⚠️ 两种例外退回整页重载:`removed_forward`(这一次及以后砍掉了整个系列尾段,
|
||||||
@@ -135,7 +139,15 @@
|
|||||||
*/
|
*/
|
||||||
function paintDetail(data) {
|
function paintDetail(data) {
|
||||||
var isEvent = (data.source === 'event');
|
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>'; }
|
if (bodyEl()) { bodyEl().innerHTML = '<dl class="kv">' + rowsHtml(data.rows) + '</dl>'; }
|
||||||
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
|
/* 🔴 v78:任务带 `panel`(可就地操作的那块)—— 面板比只读信息宽一号,
|
||||||
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
|
所以这里按**有没有 panel** 决定宽度,而不是按 source。 */
|
||||||
@@ -172,7 +184,16 @@
|
|||||||
/* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
|
/* 🔴 用户:「来自其他模块时,下面就没有编辑、也没有删除,就只有一个详情」——
|
||||||
所以这两颗**只对 source='event'** 显(且各要有地址)。 */
|
所以这两颗**只对 source='event'** 显(且各要有地址)。 */
|
||||||
var ed = byId('fbEntryEdit');
|
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');
|
var de = byId('fbEntryDelete');
|
||||||
if (de) { de.hidden = !(isEvent && data.del); }
|
if (de) { de.hidden = !(isEvent && data.del); }
|
||||||
|
|
||||||
@@ -204,6 +225,41 @@
|
|||||||
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
|
if (!bodyEl() || html.indexOf('data-tk-panel') < 0) { throw new Error('panel'); }
|
||||||
bodyEl().innerHTML = html;
|
bodyEl().innerHTML = html;
|
||||||
runInlineScripts(bodyEl(), 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 () {
|
.catch(function () {
|
||||||
if (!bodyEl()) { return; }
|
if (!bodyEl()) { return; }
|
||||||
@@ -308,14 +364,93 @@
|
|||||||
.catch(function () { window.location.href = url; });
|
.catch(function () { window.location.href = url; });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑态「保存」= 原生提交那张表单(服务端 update 路由会跳转,刷新到新页)。 */
|
/** 找到编辑态那张表单(四个来源的 id 不同,兜底取第一个 form)。 */
|
||||||
|
function editForm() {
|
||||||
|
var b = bodyEl();
|
||||||
|
if (!b) { return null; }
|
||||||
|
return b.querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|
||||||
|
|| b.querySelector('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑态「保存」。
|
||||||
|
*
|
||||||
|
* 🔵 工单 #37(用户 2026-11-01)——**用户原话**:
|
||||||
|
* 「修改一个日程时,页面会刷新,这样如果我新建或者修改的日程不是今天的,
|
||||||
|
* 那么当刷新后,就看不到我编辑的日程的变化,不知道修改成功了没有。
|
||||||
|
* 修改和新建,都不应该刷新页面,而是使用js动态调整。」
|
||||||
|
*
|
||||||
|
* ⇒ **日程**(`#calEditForm`)改走 AJAX + 原地重绘;
|
||||||
|
* 另外三个来源(名册 / 观影 / 行程 / 任务…)保持原生提交 ——
|
||||||
|
* 它们各自的 update 路由没有 JSON 出口,硬改就得把四个模块一起动,
|
||||||
|
* 而用户这次报的是日程。范围收在日程上,其它来源行为一字不变(避免回归)。
|
||||||
|
*/
|
||||||
function submitEdit() {
|
function submitEdit() {
|
||||||
var form = bodyEl() && (bodyEl().querySelector('form#anForm, form#mvForm, form#nodeForm, form#tripForm, form#calEditForm')
|
var form = editForm();
|
||||||
|| bodyEl().querySelector('form'));
|
|
||||||
if (!form) { setTip('找不到可保存的表单。', true); return; }
|
if (!form) { setTip('找不到可保存的表单。', true); return; }
|
||||||
form.setAttribute('data-fb-entry-submit', '1');
|
|
||||||
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
/* 只有日程那张表有 AJAX 出口(`calendar/event_update` 认 X-Requested-With)。
|
||||||
else { form.submit(); }
|
其余来源照旧原生提交,与改动前完全一致。 */
|
||||||
|
/* ⚠️⚠️ 必须用 `getAttribute('id')`,**不能**写 `form.id`:
|
||||||
|
日程编辑表里有 `<input type="hidden" name="id">`,而 HTMLFormElement 的具名访问
|
||||||
|
优先级高于原型上的 `id` 属性 ⇒ `form.id` 拿到的是那个 input 元素。
|
||||||
|
原来那句 `form.id === 'calEditForm'` 因此**恒为 false**,日程也走了原生提交、
|
||||||
|
整页刷新(本轮实测抓到:请求上没有 X-Requested-With,响应 302)。 */
|
||||||
|
var isCal = form.getAttribute('id') === 'calEditForm'
|
||||||
|
&& typeof window.fetch === 'function';
|
||||||
|
if (!isCal) {
|
||||||
|
form.setAttribute('data-fb-entry-submit', '1');
|
||||||
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
|
else { form.submit(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitEditAsync(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日程:fetch 提交 → 成功则关浮层 + 局部重绘;失败留在浮层里让人改。 */
|
||||||
|
function submitEditAsync(form) {
|
||||||
|
var saveBtn = byId('fbEntrySave');
|
||||||
|
if (saveBtn) { saveBtn.disabled = true; }
|
||||||
|
setTip('正在保存…');
|
||||||
|
footNone(); /* 保存中把按钮收起来,防连点(重复提交在重复日程上后果很重) */
|
||||||
|
|
||||||
|
fetch(form.getAttribute('action'), {
|
||||||
|
method: 'POST', body: new FormData(form), credentials: 'same-origin',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return null; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (saveBtn) { saveBtn.disabled = false; }
|
||||||
|
if (d && d.ok) {
|
||||||
|
setOpen(false);
|
||||||
|
/* `moved_series`(改「这一次」/「从现在到以后」)= 整条系列都变了,
|
||||||
|
影响面超出当前这一屏 ⇒ 交给 refresh 自己判断要不要硬刷。 */
|
||||||
|
var sync = window.fbCalSync;
|
||||||
|
if (sync && typeof sync.refresh === 'function') {
|
||||||
|
sync.refresh({ msg: (d.msg || '已保存。'), hard: !!d.moved_series });
|
||||||
|
} else {
|
||||||
|
/* 没加载到脚本(老缓存):退回刷新,别当成功 */
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* 业务拒绝(没权限 / 时间不对 / 标题为空):留在浮层里,让用户改完再存 */
|
||||||
|
if (d && d.msg) {
|
||||||
|
setTip(d.msg, true);
|
||||||
|
footShow('edit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话(与改动前一致) */
|
||||||
|
form.setAttribute('data-fb-entry-submit', '1');
|
||||||
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
|
else { form.submit(); }
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (saveBtn) { saveBtn.disabled = false; }
|
||||||
|
form.setAttribute('data-fb-entry-submit', '1');
|
||||||
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
|
else { form.submit(); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ③ 详情:就地展开更多信息 ----------------------------------------------
|
/* ---- ③ 详情:就地展开更多信息 ----------------------------------------------
|
||||||
|
|||||||
+64
-5
@@ -70,6 +70,8 @@
|
|||||||
var kind = trigger.getAttribute('data-prev') || 'image';
|
var kind = trigger.getAttribute('data-prev') || 'image';
|
||||||
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
||||||
var dl = trigger.getAttribute('data-dl') || src;
|
var dl = trigger.getAttribute('data-dl') || src;
|
||||||
|
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
|
||||||
|
var nm2 = trigger.getAttribute('data-name') || '';
|
||||||
if (!src) { return false; }
|
if (!src) { return false; }
|
||||||
opener = trigger;
|
opener = trigger;
|
||||||
clear();
|
clear();
|
||||||
@@ -82,6 +84,37 @@
|
|||||||
el.controls = true;
|
el.controls = true;
|
||||||
el.autoplay = true;
|
el.autoplay = true;
|
||||||
el.playsInline = 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') {
|
} else if (kind === 'pdf') {
|
||||||
el = document.createElement('iframe');
|
el = document.createElement('iframe');
|
||||||
el.className = 'pv-pdf';
|
el.className = 'pv-pdf';
|
||||||
@@ -153,9 +186,28 @@
|
|||||||
cur = -1;
|
cur = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */
|
/**
|
||||||
function isImage() {
|
* 按键是否发生在**媒体控件内部**(视频 / PDF iframe)。
|
||||||
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
|
*
|
||||||
|
* 🔴 #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) {
|
document.addEventListener('click', function (e) {
|
||||||
@@ -177,7 +229,14 @@
|
|||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (box.hidden) { return; }
|
if (box.hidden) { return; }
|
||||||
if (e.key === 'Escape') { close(); 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); }
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+432
-8
@@ -78,6 +78,29 @@
|
|||||||
var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
|
var ti2 = b.querySelector('.tk-inline-form input[name="title"]');
|
||||||
if (ti2) { ti2.value = draftTitle; }
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,21 +111,79 @@
|
|||||||
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
|
* 用户改完关掉浮层,如果卡片上还写着「待办」,他会怀疑"到底改上了没有"。
|
||||||
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
|
* 只改状态徽标与复选框 —— 看板徽标/逾期色这些要重算的东西留给下次刷新,
|
||||||
* 不在这里猜(猜错比不猜更糟)。
|
* 不在这里猜(猜错比不猜更糟)。
|
||||||
|
*
|
||||||
|
* 🔴🔴 工单 #20(用户 2026-10-28,第二次提同一条需求):
|
||||||
|
* 「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」
|
||||||
|
* 第一版只改了行首那颗复选框(home.php 里的脚本),**漏了这里**——
|
||||||
|
* 从面板点「已完成」时,行留在原地只把徽标改成「已完成」。
|
||||||
|
* 同一个用户动作(都是"标成完成")两条路径两种结果,用户复现的就是这条。
|
||||||
|
*
|
||||||
|
* ⇒ 变更后是 done 时,也把这一行从首页卡片里摘掉。
|
||||||
|
* 真正干活的在 app.js 的 `window.fbHomeTaskDrop()`:它自己判断
|
||||||
|
* "行在不在 `[data-tk-home-host]` 里",**不在就完全不碰**
|
||||||
|
* (任务看板页的行也在 `[data-task-row]` 上,那里必须保留该行)。
|
||||||
|
*
|
||||||
|
* ⚠️ `d.status` 是**父任务**的状态(`task_panel_json()` 用 `panel_of` 找的父任务,
|
||||||
|
* 见 app/helpers.php:506)。面板里勾的是**子任务**时,请求带 `panel_of`,
|
||||||
|
* 回来的 status 仍是父任务的 ⇒ 这里不会因为"子任务做完了"就删掉父任务那一行。
|
||||||
|
*
|
||||||
|
* ⚠️ 删行前先把 `wasStatus` 取出来:下面几行会改写 `data-tk-status`,
|
||||||
|
* 改完再读就只能读到 done,概述卡不知道该减「在做」还是「待办」。
|
||||||
*/
|
*/
|
||||||
function syncRow(d) {
|
function syncRow(d) {
|
||||||
if (!d || !d.status) { return; }
|
if (!d || !d.status) { return; }
|
||||||
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
var row = document.querySelector('[data-task-row="' + taskId() + '"]');
|
||||||
if (!row) { return; }
|
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') : '';
|
||||||
|
|
||||||
var badge = row.querySelector('.kb-st');
|
var badge = row.querySelector('.kb-st');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.className = 'kb-st is-' + d.status + ' is-static';
|
badge.className = 'kb-st is-' + d.status + ' is-static';
|
||||||
if (d.word) { badge.textContent = d.word; }
|
if (d.word) { badge.textContent = d.word; }
|
||||||
}
|
}
|
||||||
var chk = row.querySelector('.tk-check[data-tk-done]');
|
if (chk0) {
|
||||||
if (chk) {
|
chk0.setAttribute('data-tk-status', d.status);
|
||||||
chk.setAttribute('data-tk-status', d.status);
|
chk0.classList.toggle('is-done', d.status === 'done');
|
||||||
chk.classList.toggle('is-done', d.status === 'done');
|
chk0.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
|
||||||
chk.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
|
}
|
||||||
|
|
||||||
|
/* 标成完成 ⇒ 从首页「近期任务」里摘掉(工单 #20)。
|
||||||
|
⚠️ 用服务端回的 `d.status`,不是"我点之前猜的 next"。 */
|
||||||
|
if (d.status === 'done' && typeof window.fbHomeTaskDrop === 'function') {
|
||||||
|
window.fbHomeTaskDrop(row, was);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +193,13 @@
|
|||||||
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
|
* @param {FormData} fd 已经带好 `_csrf` / `ajax_panel` 的请求体
|
||||||
* @param {boolean} keepDraft 换面板时要不要保住用户正在打的字(传附件时为 true)
|
* @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 (!url) { setTip('这一项暂时不能在首页改。', true); return; }
|
||||||
if (!window.fetch || !window.FormData) { window.location.reload(); return; }
|
if (!window.fetch || !window.FormData) { window.location.reload(); return; }
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
@@ -124,6 +211,20 @@
|
|||||||
return r.json().catch(function () { return null; });
|
return r.json().catch(function () { return null; });
|
||||||
}).then(function (d) {
|
}).then(function (d) {
|
||||||
if (!d) { window.location.reload(); return; } // 会话过期 / 500:整页重载最诚实
|
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);
|
paint(d, keepDraft);
|
||||||
syncRow(d);
|
syncRow(d);
|
||||||
setTip(d.msg || '', !d.ok);
|
setTip(d.msg || '', !d.ok);
|
||||||
@@ -131,13 +232,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把「id + 几个附加字段」包成一个动作请求 */
|
/** 把「id + 几个附加字段」包成一个动作请求 */
|
||||||
function act(url, extra) {
|
/**
|
||||||
|
* 发一个面板动作。
|
||||||
|
*
|
||||||
|
* @param url 目标地址
|
||||||
|
* @param extra 额外字段
|
||||||
|
* @param closeOnOk #78:成功后**关掉浮层**(只有「保存」用)。
|
||||||
|
* 用户 2026-11-05:「点击保存之后,按道理说应该直接把这个窗口关闭才对呀。」
|
||||||
|
* ⚠️ 只给「保存」用:改状态 / 挪看板 / 加子任务 / 发评论 / 传附件
|
||||||
|
* 都还要继续在这个面板里操作,关窗会把用户赶走。
|
||||||
|
*/
|
||||||
|
function act(url, extra, closeOnOk) {
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('_csrf', csrf());
|
fd.append('_csrf', csrf());
|
||||||
fd.append('id', taskId());
|
fd.append('id', taskId());
|
||||||
fd.append('ajax_panel', '1');
|
fd.append('ajax_panel', '1');
|
||||||
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
|
Object.keys(extra || {}).forEach(function (k) { fd.append(k, extra[k]); });
|
||||||
send(url, fd);
|
send(url, fd, false, closeOnOk);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
|
/* ---- ① 表单提交(加子任务 / 发评论 / 删附件)------------------------------
|
||||||
@@ -193,6 +304,180 @@
|
|||||||
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
|
act(urlOf('move'), { board: btn.getAttribute('data-board') || '0' });
|
||||||
return;
|
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') {
|
if (what === 'cmt-del') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!window.confirm('删掉这条留言?')) { return; }
|
if (!window.confirm('删掉这条留言?')) { return; }
|
||||||
@@ -239,6 +524,144 @@
|
|||||||
if (inp) { inp.click(); }
|
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,否则浏览器不认这是落点(还会当"打开文件") */
|
/* 拖到框上要给 dragover preventDefault,否则浏览器不认这是落点(还会当"打开文件") */
|
||||||
document.addEventListener('dragover', function (e) {
|
document.addEventListener('dragover', function (e) {
|
||||||
var t = e.target;
|
var t = e.target;
|
||||||
@@ -316,4 +739,5 @@
|
|||||||
}
|
}
|
||||||
step();
|
step();
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ use App\CalDav;
|
|||||||
use App\Calendar;
|
use App\Calendar;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Ics;
|
use App\Ics;
|
||||||
|
use App\TaskDav;
|
||||||
|
|
||||||
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
||||||
function dav_fail(int $code, string $msg): void
|
function dav_fail(int $code, string $msg): void
|
||||||
@@ -317,6 +318,96 @@ $emit = static function (string $xmlns, array $parts): void {
|
|||||||
|
|
||||||
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
|
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
|
||||||
if ($method === 'PROPFIND') {
|
if ($method === 'PROPFIND') {
|
||||||
|
/* ============================================================
|
||||||
|
* 待办本(VTODO)—— 必须在原有日历逻辑之前拦下
|
||||||
|
* ============================================================
|
||||||
|
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
|
||||||
|
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
|
||||||
|
*/
|
||||||
|
if (TaskDav::isTaskPath($path)) {
|
||||||
|
/* ============================================================
|
||||||
|
* 待办本(VTODO)—— 按分类多本,与参照 DAViCal 同构
|
||||||
|
* ============================================================
|
||||||
|
* 用户 2026-11-02 给了参照服务器并指出
|
||||||
|
* 「应该可以直接显示那个家庭的待办分类」。
|
||||||
|
* 参照实测:每个分类是独立的 calendar 集合,各自 comp 只含 VTODO。
|
||||||
|
*
|
||||||
|
* ⚠️ supported-calendar-component-set 必须是 VTODO:
|
||||||
|
* 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。
|
||||||
|
*/
|
||||||
|
$tHit = TaskDav::parse($path);
|
||||||
|
$tSeg = $tHit === null ? '' : (string)$tHit['seg'];
|
||||||
|
$tBook = TaskDav::resolveBook($familyId, $me, $tSeg);
|
||||||
|
if ($tBook === null) {
|
||||||
|
dav_fail(404, '没有这本待办。');
|
||||||
|
}
|
||||||
|
$tCatId = (int)$tBook['id'];
|
||||||
|
$tDepth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||||
|
|
||||||
|
$taskEmit = static function (string $xmlns, array $items): void {
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
http_response_code(207);
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus ' . $xmlns . '>' . "\n"
|
||||||
|
. implode("\n", $items) . "\n"
|
||||||
|
. '</d:multistatus>';
|
||||||
|
exit;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($tDepth !== '1') {
|
||||||
|
// Depth:0 —— 这一本自身的属性
|
||||||
|
$ctag = TaskDav::ctag($familyId, $me, $tCatId);
|
||||||
|
$taskEmit(
|
||||||
|
'xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||||
|
. 'xmlns:cs="http://calendarserver.org/ns/" '
|
||||||
|
. 'xmlns:x="http://apple.com/ns/ical/"',
|
||||||
|
[
|
||||||
|
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
|
||||||
|
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
|
||||||
|
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '</c:calendar-description>'
|
||||||
|
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tBook['color']) . '</x:calendar-color>'
|
||||||
|
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||||
|
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
|
||||||
|
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
|
||||||
|
. '<d:current-user-privilege-set><d:privilege><d:all/></d:privilege></d:current-user-privilege-set>'
|
||||||
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Depth:1 —— 本 + 里面的资源清单。
|
||||||
|
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
|
||||||
|
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
|
||||||
|
$items = [
|
||||||
|
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
|
||||||
|
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||||
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
|
||||||
|
];
|
||||||
|
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
|
||||||
|
$seg = TaskDav::segmentOf($t);
|
||||||
|
if ($seg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$etag = TaskDav::resourceOf($t, $tBook['name'])['etag'];
|
||||||
|
$items[] = '<d:response><d:href>'
|
||||||
|
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
||||||
|
. '<d:resourcetype/>'
|
||||||
|
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
||||||
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||||
|
}
|
||||||
|
$taskEmit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $items);
|
||||||
|
}
|
||||||
|
|
||||||
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||||
|
|
||||||
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
|
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
|
||||||
@@ -464,6 +555,27 @@ if ($method === 'PROPFIND') {
|
|||||||
. '</d:href></c:calendar-home-set>'
|
. '</d:href></c:calendar-home-set>'
|
||||||
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
|
||||||
}
|
}
|
||||||
|
/* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。
|
||||||
|
用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。
|
||||||
|
参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。
|
||||||
|
没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */
|
||||||
|
foreach (TaskDav::books($familyId, $me) as $tb) {
|
||||||
|
$tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']);
|
||||||
|
$parts[] = '<d:response><d:href>' . CalDav::x($tb['href']) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x($tb['name']) . '</d:displayname>'
|
||||||
|
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
|
||||||
|
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '</c:calendar-description>'
|
||||||
|
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tb['color']) . '</x:calendar-color>'
|
||||||
|
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
|
||||||
|
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
|
||||||
|
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
|
||||||
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||||
|
}
|
||||||
|
|
||||||
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||||
. 'xmlns:x="http://apple.com/ns/ical/" '
|
. 'xmlns:x="http://apple.com/ns/ical/" '
|
||||||
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
|
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
|
||||||
@@ -522,6 +634,48 @@ if ($method === 'PROPFIND') {
|
|||||||
|
|
||||||
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
||||||
if ($method === 'GET' || $method === 'HEAD') {
|
if ($method === 'GET' || $method === 'HEAD') {
|
||||||
|
/* 待办本:整本导出,或单条 VTODO。
|
||||||
|
⚠️ 单条必须校验资源属于**这一本**且本用户可见,
|
||||||
|
否则改 URL 就能读到别人的任务(越权)。 */
|
||||||
|
if (TaskDav::isTaskPath($path)) {
|
||||||
|
$tHit = TaskDav::parse($path);
|
||||||
|
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||||
|
if ($tBook === null) {
|
||||||
|
dav_fail(404, '没有这本待办。');
|
||||||
|
}
|
||||||
|
$tCatId = (int)$tBook['id'];
|
||||||
|
$tSub = (string)($tHit['sub'] ?? '');
|
||||||
|
|
||||||
|
if ($tSub !== '') {
|
||||||
|
$tParsed = TaskDav::idOfSegment($tSub);
|
||||||
|
$tOne = $tParsed > 0 ? TaskDav::one($familyId, $me, $tParsed) : null;
|
||||||
|
// ⚠️ 还要确认这条**确实属于这一本**,否则可用 A 本的 href 读到 B 本的任务
|
||||||
|
if ($tOne === null) {
|
||||||
|
dav_fail(404, '没有这条待办。');
|
||||||
|
}
|
||||||
|
$tOwnCat = (int)($tOne['category_id'] ?? 0);
|
||||||
|
$tExpectCat = $tCatId === -1 ? 0 : $tCatId;
|
||||||
|
if ($tOwnCat !== $tExpectCat) {
|
||||||
|
dav_fail(404, '这条待办不在这本里。');
|
||||||
|
}
|
||||||
|
$tRes = TaskDav::resourceOf($tOne, $tBook['name']);
|
||||||
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
|
header('ETag: ' . $tRes['etag']);
|
||||||
|
if ($method !== 'HEAD') {
|
||||||
|
echo $tRes['ics'];
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整本
|
||||||
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
|
header('ETag: ' . TaskDav::ctag($familyId, $me, $tCatId));
|
||||||
|
if ($method !== 'HEAD') {
|
||||||
|
echo TaskDav::ics($familyId, $me, $tCatId, $tBook['name']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
|
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
|
||||||
原来这一段是:
|
原来这一段是:
|
||||||
if (!$cal) { dav_fail(404, '没有这本日历。'); }
|
if (!$cal) { dav_fail(404, '没有这本日历。'); }
|
||||||
@@ -596,6 +750,53 @@ if ($method === 'GET' || $method === 'HEAD') {
|
|||||||
|
|
||||||
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
||||||
if ($method === 'REPORT') {
|
if ($method === 'REPORT') {
|
||||||
|
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
|
||||||
|
⚠️ 支持 multiget 按 href 过滤 —— 整本回在条目多时是纯浪费。 */
|
||||||
|
if (TaskDav::isTaskPath($path)) {
|
||||||
|
$tHit = TaskDav::parse($path);
|
||||||
|
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||||
|
if ($tBook === null) {
|
||||||
|
dav_fail(404, '没有这本待办。');
|
||||||
|
}
|
||||||
|
$tCatId = (int)$tBook['id'];
|
||||||
|
$tBody = (string)file_get_contents('php://input');
|
||||||
|
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
|
||||||
|
|
||||||
|
$tWant = [];
|
||||||
|
if ($tWantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $tBody, $mm)) {
|
||||||
|
foreach ($mm[1] as $h) {
|
||||||
|
$h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||||
|
$seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/')));
|
||||||
|
if ($seg !== '') {
|
||||||
|
$tWant[$seg] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tItems = [];
|
||||||
|
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
|
||||||
|
$seg = TaskDav::segmentOf($t);
|
||||||
|
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$res = TaskDav::resourceOf($t, $tBook['name']);
|
||||||
|
$tItems[] = '<d:response><d:href>'
|
||||||
|
. CalDav::x($tBook['href'] . $seg . '.ics') . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
||||||
|
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
||||||
|
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
http_response_code(207);
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n"
|
||||||
|
. implode("\n", $tItems) . "\n"
|
||||||
|
. '</d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
|
/* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
|
||||||
照参照(DAViCal)回 **403**,不回 404。
|
照参照(DAViCal)回 **403**,不回 404。
|
||||||
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
|
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
|
||||||
@@ -706,6 +907,51 @@ if ($method === 'REPORT') {
|
|||||||
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
|
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
|
||||||
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
|
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
|
/* 待办本:新建 / 更新一条 VTODO。
|
||||||
|
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编名字)。
|
||||||
|
⚠️ PUT 到不存在的资源名是**创建**(RFC 4918 §9.7),
|
||||||
|
由 TaskDav::put 内部判断,这里不预先拒绝。 */
|
||||||
|
if (TaskDav::isTaskPath($path)) {
|
||||||
|
$tHit = TaskDav::parse($path);
|
||||||
|
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||||
|
if ($tBook === null) {
|
||||||
|
dav_fail(404, '没有这本待办。');
|
||||||
|
}
|
||||||
|
$tCatId = (int)$tBook['id'];
|
||||||
|
|
||||||
|
$tBody = (string)file_get_contents('php://input');
|
||||||
|
if (trim($tBody) === '') {
|
||||||
|
dav_fail(400, '正文是空的。');
|
||||||
|
}
|
||||||
|
$tCtype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||||
|
if ($tCtype !== '' && stripos($tCtype, 'calendar') === false
|
||||||
|
&& stripos($tCtype, 'text/plain') === false) {
|
||||||
|
dav_fail(415, '只接受 text/calendar 正文。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tRes2 = TaskDav::put($familyId, $me, $tCatId, $tTaskId, $tBody);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
dav_fail(403, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$tSaved = TaskDav::one($familyId, $me, (int)$tRes2['id']);
|
||||||
|
if (!$tSaved) {
|
||||||
|
dav_fail(500, '写入后读取失败。');
|
||||||
|
}
|
||||||
|
$tRes3 = TaskDav::resourceOf($tSaved, $tBook['name']);
|
||||||
|
$tNewHref = $tBook['href'] . TaskDav::segmentOf($tSaved) . '.ics';
|
||||||
|
|
||||||
|
http_response_code($tRes2['created'] ? 201 : 204);
|
||||||
|
header('ETag: ' . $tRes3['etag']);
|
||||||
|
if ($tRes2['created']) {
|
||||||
|
header('Location: ' . $tNewHref);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$cal) {
|
if (!$cal) {
|
||||||
dav_fail(404, '没有这本日历。');
|
dav_fail(404, '没有这本日历。');
|
||||||
}
|
}
|
||||||
@@ -898,6 +1144,23 @@ if ($method === 'PUT') {
|
|||||||
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
|
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
|
||||||
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
|
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
|
if (TaskDav::isTaskPath($path)) {
|
||||||
|
$tHit = TaskDav::parse($path);
|
||||||
|
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
|
||||||
|
if ($tBook === null) {
|
||||||
|
dav_fail(404, '没有这本待办。');
|
||||||
|
}
|
||||||
|
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
|
||||||
|
if ($tTaskId <= 0) {
|
||||||
|
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks-3.ics/task-5.ics)。');
|
||||||
|
}
|
||||||
|
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
|
||||||
|
dav_fail(404, '没有这条待办,或你没有删除权限。');
|
||||||
|
}
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$cal) {
|
if (!$cal) {
|
||||||
dav_fail(404, '没有这本日历。');
|
dav_fail(404, '没有这本日历。');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,59 @@ function media_fail(int $code, string $message): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$key = (string)($_GET['k'] ?? '');
|
$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'] ?? '');
|
$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 ($isImageKey) {
|
||||||
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
|
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
|
||||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
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);
|
$status = (int)($res['status'] ?? 0);
|
||||||
$body = (string)($res['body'] ?? '');
|
$body = (string)($res['body'] ?? '');
|
||||||
|
|
||||||
// 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
|
/* 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
|
||||||
|
🔴 #56:**按尺寸分档存** —— 否则 400/800/1600 三档会互相覆盖,
|
||||||
|
用户看到的尺寸随机。原图($imgW=0)仍存原路径,行为不变。 */
|
||||||
if ($isImageKey && !empty($res['ok']) && $status === 200
|
if ($isImageKey && !empty($res['ok']) && $status === 200
|
||||||
&& ($res['from'] ?? '') !== 'local' && $body !== '') {
|
&& ($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) {
|
if (empty($res['ok']) || $status < 200 || $status >= 300) {
|
||||||
@@ -217,8 +288,25 @@ $etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
|
|||||||
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
|
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
|
||||||
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
|
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
|
||||||
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
|
(真要连本地读都省掉,得把 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'] ?? ''));
|
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
|
||||||
if ($inm !== '' && $inm === $etag) {
|
if (!$hasRange && $inm !== '' && $inm === $etag) {
|
||||||
http_response_code(304);
|
http_response_code(304);
|
||||||
header('ETag: ' . $etag);
|
header('ETag: ' . $etag);
|
||||||
header('Cache-Control: private, max-age=604800');
|
header('Cache-Control: private, max-age=604800');
|
||||||
@@ -261,6 +349,9 @@ if ($isRange) {
|
|||||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||||
}
|
}
|
||||||
header('Accept-Ranges: bytes');
|
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);
|
$filename = basename($key);
|
||||||
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
|
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
|
||||||
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
|
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
|
||||||
|
|||||||
@@ -49,7 +49,21 @@ $attDelUrl = $attDelUrl ?? '';
|
|||||||
<?php
|
<?php
|
||||||
$attKind = \App\Attachment::kind($attRow);
|
$attKind = \App\Attachment::kind($attRow);
|
||||||
$canPrev = \App\Attachment::previewable($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]);
|
$attDl = media_url((string)$attRow['key'], ['dl' => 1]);
|
||||||
$attNm = (string)($attRow['name'] ?? '');
|
$attNm = (string)($attRow['name'] ?? '');
|
||||||
$attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0);
|
$attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0);
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ $evLab = static function (string $text, string $for = '', bool $req = false) use
|
|||||||
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
|
⇒ 那句 `.field-hint` 整块删除(不再输出)。 */ ?>
|
||||||
|
|
||||||
<?php echo $evRowOpen(); ?>
|
<?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">
|
<label class="chk-inline">
|
||||||
<input type="checkbox" id="ev-allday" name="all_day" value="1"
|
<input type="checkbox" id="ev-allday" name="all_day" value="1"
|
||||||
<?= $evIsAllDay ? 'checked' : '' ?>>
|
<?= $evIsAllDay ? 'checked' : '' ?>>
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 日历视图区(月 / 周 / 日)—— 从 templates/calendar/index.php 抽出来的独立片段。
|
||||||
|
*
|
||||||
|
* 🔴 工单 #37:新建 / 修改日程后要"不刷新页面、就地更新"。做法是**服务端渲染同一份
|
||||||
|
* 片段 → 前端整块替换**,而不是让 JS 手写复刻渲染逻辑。
|
||||||
|
* 这就必须把视图区从页面模板里抽出来,让整页路径与局部重绘走同一份代码 ——
|
||||||
|
* 只有一处渲染,"插进去的样子"与"刷新一次的样子"才不可能不一致。
|
||||||
|
*
|
||||||
|
* 抽出的方式:原文**一字未改**地搬过来,只把外层缩进调平。
|
||||||
|
* 所以这里用的变量与原来完全一样,由调用方给全:
|
||||||
|
*
|
||||||
|
* @var string $view month / week / day
|
||||||
|
* @var array $cols 周/日视图的列(day_columns 产出)
|
||||||
|
* @var array $tgrid build_time_grid 产出
|
||||||
|
* @var array $weeks build_month_grid 产出
|
||||||
|
* @var bool $canNew 能不能新建
|
||||||
|
* @var float $nowTop 当前时间红线位置(%,服务端算好)
|
||||||
|
*
|
||||||
|
* ⚠️ 调用方必须**在 require 之前**把上面这些变量准备好;本片段自己不做任何兜底
|
||||||
|
* —— 兜底会让"少给了一个变量"变成静默画错,而不是当场报错。
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<?php /* 🔴 工单 #37:这个外层容器是**局部重绘的落点**。
|
||||||
|
JS 拿到服务端渲染的新片段后,只替换 `[data-cal-view]` 里面的内容
|
||||||
|
(换的是 .card 那一层,见 assets/js/cal_sync.js 的 swap())。
|
||||||
|
⚠️ 容器本身**不能**被替换掉:它上面挂着"当前视图"这个上下文,
|
||||||
|
替换容器会让 CSS 上依赖 .cal-main > * 的选择器重新算一遍(视觉会跳一下)。 */ ?>
|
||||||
|
<div class="cal-view" data-cal-view data-cal-view-kind="<?= e((string)$view) ?>" data-cal-ym="<?= e((string)($ym ?? '')) ?>" data-cal-anchor="<?= e((string)($anchor ?? '')) ?>">
|
||||||
|
<?php if ($view === 'week' || $view === 'day'): ?>
|
||||||
|
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
|
||||||
|
<div class="card cal-tg-card">
|
||||||
|
<div class="tg-head">
|
||||||
|
<div class="tg-gutter"></div>
|
||||||
|
<?php foreach (($cols ?? []) as $c): ?>
|
||||||
|
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
|
||||||
|
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
|
||||||
|
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
|
||||||
|
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
|
||||||
|
<span class="tg-dd"><?= (int)$c['day'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
|
||||||
|
<?php if (array_filter($tgAll)): ?>
|
||||||
|
<div class="tg-allday">
|
||||||
|
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
|
||||||
|
<?php foreach (($cols ?? []) as $i => $c): ?>
|
||||||
|
<div class="tg-adcell">
|
||||||
|
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
|
||||||
|
<?php
|
||||||
|
$evKind = (string)($ev['kind'] ?? 'custom');
|
||||||
|
$evRef = (int)($ev['ref_id'] ?? 0);
|
||||||
|
$evEdit = '';
|
||||||
|
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
||||||
|
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
?>
|
||||||
|
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
|
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>;"
|
||||||
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
|
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="tg-body">
|
||||||
|
<div class="tg-gutter tg-hours">
|
||||||
|
<?php for ($h = 0; $h < 24; $h++): ?>
|
||||||
|
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<?php foreach (($cols ?? []) as $i => $c): ?>
|
||||||
|
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
|
||||||
|
data-new-at="<?= e((string)$c['date']) ?>">
|
||||||
|
<?php for ($h = 0; $h < 24; $h++): ?>
|
||||||
|
<span class="tg-line"></span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
|
||||||
|
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
|
||||||
|
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
|
||||||
|
来表示当前时间在视图上的刻度和位置。」
|
||||||
|
|
||||||
|
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
|
||||||
|
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
|
||||||
|
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
|
||||||
|
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
|
||||||
|
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
|
||||||
|
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
|
||||||
|
过一小时线还停在那儿就荒谬了。
|
||||||
|
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
|
||||||
|
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
|
||||||
|
<?php if ($c['is_today'] && !empty($nowTop)): ?>
|
||||||
|
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
|
||||||
|
title="现在 <?= e(date('H:i')) ?>"></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
|
||||||
|
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
|
||||||
|
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
|
||||||
|
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
|
||||||
|
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
|
||||||
|
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
|
||||||
|
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
|
||||||
|
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
|
||||||
|
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
|
||||||
|
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
|
||||||
|
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
|
||||||
|
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
|
||||||
|
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
|
||||||
|
<?php
|
||||||
|
$evKind = (string)($ev['kind'] ?? 'custom');
|
||||||
|
$evRef = (int)($ev['ref_id'] ?? 0);
|
||||||
|
$evDet = (string)($ev['href'] ?? '');
|
||||||
|
$evEdit = '';
|
||||||
|
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
||||||
|
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
|
||||||
|
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
|
||||||
|
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
|
||||||
|
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
|
||||||
|
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
|
||||||
|
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
|
||||||
|
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
|
||||||
|
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
|
||||||
|
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
|
||||||
|
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
|
||||||
|
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
|
||||||
|
if ($evE !== null && $evE <= $evS) { $evE = null; }
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
/* 🔴 v88(工单 #35):跨天日程的分段。
|
||||||
|
`seg` 由 `build_time_grid` 给出:
|
||||||
|
start = 起始段(从开始时刻到当日 24:00)
|
||||||
|
mid = 中间整天
|
||||||
|
end = 结束段(00:00 到结束时刻)
|
||||||
|
only / 空 = 单天日程(原样)
|
||||||
|
视觉上要让三段"连成一条":把段与段之间的圆角和内侧边框去掉,
|
||||||
|
只保留最外两端是圆的。具体在 CSS 的 `.tg-block.is-seg-*` 里。 */
|
||||||
|
$evSeg = (string)($ev['seg'] ?? '');
|
||||||
|
$evCont = !empty($ev['cont']);
|
||||||
|
$segCls = '';
|
||||||
|
if ($evSeg === 'start') { $segCls = ' is-seg-start'; }
|
||||||
|
elseif ($evSeg === 'mid') { $segCls = ' is-seg-mid'; }
|
||||||
|
elseif ($evSeg === 'end') { $segCls = ' is-seg-end'; }
|
||||||
|
/* 分隔线:起始段的下边不画(要跟下一段接上),
|
||||||
|
中间段上下都不画,结束段上边不画 —— 由 CSS 处理。 */
|
||||||
|
?>
|
||||||
|
<a class="tg-block<?= $segCls ?>" data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
|
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
|
||||||
|
<?php if ($evSeg !== '') : ?>data-seg="<?= e($evSeg) ?>"<?php endif; ?>
|
||||||
|
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
|
||||||
|
<?php
|
||||||
|
/* 🔴 v88:跨天段用**贴满整列**的定位(left:0 / width:100%),
|
||||||
|
和单天块的 `calc(2px + N%)` / `calc(N% - 4px)` 不是一套。
|
||||||
|
单天块要留 2px 好跟列边框区分;跨天段要跟上一列接上,
|
||||||
|
留缝就"断成三段"了(实测 10.9px 空隙)。
|
||||||
|
⚠️ 用 CSS 类而不是内联值区分:内联 calc 里做条件太绕,
|
||||||
|
而且两边混着写以后必然有人改错一边。 */
|
||||||
|
$evSegStyle = ($evSeg !== '')
|
||||||
|
? 'left: 0; width: 100%;'
|
||||||
|
: 'left: calc(2px + ' . (float)($ev['left'] ?? 0) . '%); '
|
||||||
|
. 'width: calc(' . (float)($ev['width'] ?? 100) . '% - 4px);';
|
||||||
|
?>
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>;
|
||||||
|
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
|
||||||
|
<?= $evSegStyle ?>"
|
||||||
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
|
title="<?= e((string)$ev['title']) ?><?= $evCont ? '(接前一日)' : '' ?>">
|
||||||
|
<?php if (!$evCont && (string)($ev['time'] ?? '') !== '') : ?>
|
||||||
|
<b><?= e((string)$ev['time']) ?></b>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($evCont) : ?><span class="tg-cont">↳</span><?php endif; ?><?= e((string)$ev['title']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
|
||||||
|
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
|
||||||
|
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
|
||||||
|
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
|
||||||
|
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 json_encode 的
|
||||||
|
JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
|
||||||
|
⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
|
||||||
|
所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
|
||||||
|
$dayData = [];
|
||||||
|
foreach (($weeks ?? []) as $wk) {
|
||||||
|
foreach ($wk as $cell) {
|
||||||
|
if (!$cell['events']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items = [];
|
||||||
|
foreach ($cell['events'] as $ev) {
|
||||||
|
$evKind = (string)($ev['kind'] ?? 'custom');
|
||||||
|
$evRef = (int)($ev['ref_id'] ?? 0);
|
||||||
|
$evEdit = '';
|
||||||
|
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
||||||
|
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
$items[] = ['_rank' => \App\Calendar::displayRank($ev),
|
||||||
|
'_time' => (string)($ev['time'] ?? '')]
|
||||||
|
+ json_decode(
|
||||||
|
fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$dayData[(string)$cell['date']] = $items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<script type="application/json" id="calDayData"><?= json_encode(
|
||||||
|
$dayData,
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
|
||||||
|
) ?></script>
|
||||||
|
<div class="card cal-grid-card">
|
||||||
|
<div class="cal-head">
|
||||||
|
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
|
||||||
|
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
|
||||||
|
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
|
||||||
|
<div class="cal-wd"><?= e($wd) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="cal-grid">
|
||||||
|
<?php foreach ($weeks as $wk): ?>
|
||||||
|
<?php foreach ($wk as $cell): ?>
|
||||||
|
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
|
||||||
|
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
|
||||||
|
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
|
||||||
|
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
|
||||||
|
data-new-at="<?= e((string)$cell['date']) ?>">
|
||||||
|
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
|
||||||
|
<div class="cal-evs">
|
||||||
|
<?php
|
||||||
|
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
|
||||||
|
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
|
||||||
|
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
|
||||||
|
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
|
||||||
|
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
|
||||||
|
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
|
||||||
|
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
|
||||||
|
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
|
||||||
|
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
|
||||||
|
$cellEvents = $cell['events'];
|
||||||
|
$shown = 3;
|
||||||
|
$hiddenCnt = count($cellEvents) - $shown;
|
||||||
|
?>
|
||||||
|
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
|
||||||
|
<?php if ($n <= $shown): ?>
|
||||||
|
<?php
|
||||||
|
$evKind = (string)($ev['kind'] ?? 'custom');
|
||||||
|
$evRef = (int)($ev['ref_id'] ?? 0);
|
||||||
|
$evEdit = '';
|
||||||
|
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
||||||
|
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
||||||
|
?>
|
||||||
|
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
|
||||||
|
data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
|
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>;"
|
||||||
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
|
title="<?= e((string)$ev['title']) ?>">
|
||||||
|
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
|
||||||
|
<?= e((string)$ev['title']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($hiddenCnt > 0): ?>
|
||||||
|
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
|
||||||
|
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
|
||||||
|
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
|
||||||
|
(日视图按时间网格铺开,不会再截断)。 */ ?>
|
||||||
|
<button type="button" class="cal-more"
|
||||||
|
data-cal-day="<?= e((string)$cell['date']) ?>"
|
||||||
|
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
|
||||||
|
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
|
||||||
|
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
+108
-551
@@ -187,243 +187,13 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
unset($calCanNew, $calWritable, $calCtx);
|
unset($calCanNew, $calWritable, $calCtx);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($view === 'week' || $view === 'day'): ?>
|
<?php /* 🔵 工单 #37:视图区抽成独立片段 `_view.php`。
|
||||||
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
|
整页路径与「新建/修改后的局部重绘」**必须**走同一份模板 ——
|
||||||
<div class="card cal-tg-card">
|
这样"插进去的样子"与"刷新一次的样子"不可能不一致。
|
||||||
<div class="tg-head">
|
⚠️ 原来这里是一大段内联 HTML(月/周/日三套)。抽走时**一字未改**,
|
||||||
<div class="tg-gutter"></div>
|
只把外层缩进调平;变量的给出方式也照旧($view/$cols/$tgrid/$weeks/
|
||||||
<?php foreach (($cols ?? []) as $c): ?>
|
$canNew/$nowTop 都由上面备好)。 */ ?>
|
||||||
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
|
<?php require __DIR__ . '/_view.php'; ?>
|
||||||
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
|
|
||||||
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
|
|
||||||
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
|
|
||||||
<span class="tg-dd"><?= (int)$c['day'] ?></span>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
|
|
||||||
<?php if (array_filter($tgAll)): ?>
|
|
||||||
<div class="tg-allday">
|
|
||||||
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
|
|
||||||
<?php foreach (($cols ?? []) as $i => $c): ?>
|
|
||||||
<div class="tg-adcell">
|
|
||||||
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
|
|
||||||
<?php
|
|
||||||
$evKind = (string)($ev['kind'] ?? 'custom');
|
|
||||||
$evRef = (int)($ev['ref_id'] ?? 0);
|
|
||||||
$evEdit = '';
|
|
||||||
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
|
||||||
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
?>
|
|
||||||
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
|
|
||||||
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
|
|
||||||
style="background: <?= e((string)$ev['color']) ?>;"
|
|
||||||
href="<?= e((string)$ev['href']) ?>"
|
|
||||||
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="tg-body">
|
|
||||||
<div class="tg-gutter tg-hours">
|
|
||||||
<?php for ($h = 0; $h < 24; $h++): ?>
|
|
||||||
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</div>
|
|
||||||
<?php foreach (($cols ?? []) as $i => $c): ?>
|
|
||||||
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
|
|
||||||
data-new-at="<?= e((string)$c['date']) ?>">
|
|
||||||
<?php for ($h = 0; $h < 24; $h++): ?>
|
|
||||||
<span class="tg-line"></span>
|
|
||||||
<?php endfor; ?>
|
|
||||||
<?php /* ==================== 当前时间红线(v24 第 20 批 用户第 6 条)====================
|
|
||||||
用户原话:「日程模块,周视图和日视图,应该在视图中间显示一条红线,
|
|
||||||
用来表示当前进行的时间,就是说,用一个不断从上到下的红线,
|
|
||||||
来表示当前时间在视图上的刻度和位置。」
|
|
||||||
|
|
||||||
⚠️ 只在**今天那一列**画(别的日子没有"现在"可言)。
|
|
||||||
⚠️ 位置是服务端算好的百分比(与上面 .tg-block 用的是同一套:
|
|
||||||
列高 = 24h,所以 1 分钟 = 100/1440 %)。**不在 CSS 里写死**
|
|
||||||
某个时间 —— 那就成了"固定的一条线",不叫"当前时间"。
|
|
||||||
⚠️ 线的**移动**由 app.js 里的一分钟定时器负责(`initNowLines()`):
|
|
||||||
服务端给的是"渲染那一刻"的位置,用户开着页面不走,
|
|
||||||
过一小时线还停在那儿就荒谬了。
|
|
||||||
⚠️ 全天日程的"全天"那一行**不画** —— 那条线表达的是"一天之内的时间刻度",
|
|
||||||
画在全天行上会让人以为全天日程跟时间有关。 */ ?>
|
|
||||||
<?php if ($c['is_today'] && !empty($nowTop)): ?>
|
|
||||||
<span class="tg-now" data-now-line style="top: <?= (float)$nowTop ?>%;"
|
|
||||||
title="现在 <?= e(date('H:i')) ?>"></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php /* 🔴 v30 第 4 条:时间冲突的日程**错开画**(不再堆叠)。
|
|
||||||
用户原话:「如果两个事件时间上有冲突了,现在是堆叠在一起,应该错开,
|
|
||||||
显示两个色块,如果三个冲突了,就是三个色块,错开显示。」
|
|
||||||
left/width 由 App\Calendar::conflictLanes 算好(算法借鉴 SyncCalendar
|
|
||||||
的 TimelineUtil:贪心分列,列数 = 最大同时重叠数)。
|
|
||||||
⚠️ 用**内联 left/width 百分比**而不是 CSS 类:
|
|
||||||
列数 1/2/3/4 是运行时才知道的,类名会爆炸;
|
|
||||||
而且模板已经在内联 top/height 了,同一套坐标系最不容易错。 */ ?>
|
|
||||||
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
|
|
||||||
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不再直接跳页)。
|
|
||||||
数据由 fb_entry_json() 统一编(三个入口共用一个函数)。
|
|
||||||
「详细信息」跳该来源的详情页;「编辑」取该来源的编辑表单片段。
|
|
||||||
⚠️ 编辑地址一律带可写判断:只读的(农历/节假日)不给「编辑」。 */ ?>
|
|
||||||
<?php
|
|
||||||
$evKind = (string)($ev['kind'] ?? 'custom');
|
|
||||||
$evRef = (int)($ev['ref_id'] ?? 0);
|
|
||||||
$evDet = (string)($ev['href'] ?? '');
|
|
||||||
$evEdit = '';
|
|
||||||
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
|
||||||
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
|
|
||||||
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
?>
|
|
||||||
<?php
|
|
||||||
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
|
|
||||||
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
|
|
||||||
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
|
|
||||||
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
|
|
||||||
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
|
|
||||||
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
|
|
||||||
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
|
|
||||||
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
|
|
||||||
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
|
|
||||||
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
|
|
||||||
if ($evE !== null && $evE <= $evS) { $evE = null; }
|
|
||||||
?>
|
|
||||||
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
|
|
||||||
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
|
|
||||||
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
|
|
||||||
style="background: <?= e((string)$ev['color']) ?>;
|
|
||||||
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
|
|
||||||
left: calc(2px + <?= (float)($ev['left'] ?? 0) ?>%);
|
|
||||||
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
|
|
||||||
href="<?= e((string)$ev['href']) ?>"
|
|
||||||
title="<?= e((string)$ev['title']) ?>">
|
|
||||||
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php
|
|
||||||
/* 🔴 v70:把「每格全部条目」一次性送到前端(点 "+N 条" 时浮层直接用,
|
|
||||||
不再发请求)。**只送当月格子里的日期**,量很小(一个月最多几百条)。
|
|
||||||
⚠️ 用 `<script type="application/json">` 而不是把 JSON 塞进 data-*:
|
|
||||||
data-* 属性里几千条会被 HTML 转义得又长又容易截断;
|
|
||||||
JSON 里若出现 `</script>` 也会截断 —— 所以下面统一走 json_encode 的
|
|
||||||
JSON_HEX_TAG(把 < > 转成 \u003C \u003E,永远不会提前闭合)。
|
|
||||||
⚠️ 这是**渲染层**的便利数据,字段与 `data-fb-entry` 同一套(fb_entry_json),
|
|
||||||
所以浮层能沿用全部既有逻辑(详情/编辑/删除/来源判断)。 */
|
|
||||||
$dayData = [];
|
|
||||||
foreach (($weeks ?? []) as $wk) {
|
|
||||||
foreach ($wk as $cell) {
|
|
||||||
if (!$cell['events']) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$items = [];
|
|
||||||
foreach ($cell['events'] as $ev) {
|
|
||||||
$evKind = (string)($ev['kind'] ?? 'custom');
|
|
||||||
$evRef = (int)($ev['ref_id'] ?? 0);
|
|
||||||
$evEdit = '';
|
|
||||||
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
|
||||||
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
$items[] = ['_rank' => \App\Calendar::displayRank($ev),
|
|
||||||
'_time' => (string)($ev['time'] ?? '')]
|
|
||||||
+ json_decode(
|
|
||||||
fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$dayData[(string)$cell['date']] = $items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<script type="application/json" id="calDayData"><?= json_encode(
|
|
||||||
$dayData,
|
|
||||||
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
|
|
||||||
) ?></script>
|
|
||||||
<div class="card cal-grid-card">
|
|
||||||
<div class="cal-head">
|
|
||||||
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
|
|
||||||
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
|
|
||||||
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
|
|
||||||
<div class="cal-wd"><?= e($wd) ?></div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<div class="cal-grid">
|
|
||||||
<?php foreach ($weeks as $wk): ?>
|
|
||||||
<?php foreach ($wk as $cell): ?>
|
|
||||||
<?php /* 用户第 5 条:点格子里的空白 = 在这天建日程。
|
|
||||||
用 data-new-at 把日期带过去,由底部 JS 交给新建表单
|
|
||||||
(点了小条本身是"打开那条日程",由 <a> 自己处理,JS 会排除掉)。 */ ?>
|
|
||||||
<div class="cal-cell<?= $cell['in_month'] ? '' : ' is-out' ?><?= $cell['is_today'] ? ' is-today' : '' ?><?= $canNew ? ' rt-writable' : '' ?>"
|
|
||||||
data-new-at="<?= e((string)$cell['date']) ?>">
|
|
||||||
<div class="cal-dnum"><?= (int)$cell['day'] ?></div>
|
|
||||||
<div class="cal-evs">
|
|
||||||
<?php
|
|
||||||
/* 🔴 v70(用户 2026-10-20 第 2 条):「日历导入进去了,但是不显示」。
|
|
||||||
真根因不在这里的 3 条,而在**排序 + "+N 条"点不开**的组合:
|
|
||||||
每格只画 3 条,而旧排序把"全天"(时间空串)排最前 ⇒ 农历/节气/
|
|
||||||
节假日天天占满前三,用户自己导入的日程全被挤进 "+N 条",
|
|
||||||
而那个 "+N 条" 只是个 <span>,点不开 = 完全看不到。
|
|
||||||
两处都已修:① `Calendar::displayRank()` 让派生条目排最后;
|
|
||||||
② 下面把 "+N 条" 换成**真按钮**,点开当天的全部条目浮层。
|
|
||||||
⚠️ 保留 3 条不改成"全画":格子是固定高度(min-height:104px),
|
|
||||||
15 条硬塞会把月网格撑得不像月历;超出部分交给浮层看。 */
|
|
||||||
$cellEvents = $cell['events'];
|
|
||||||
$shown = 3;
|
|
||||||
$hiddenCnt = count($cellEvents) - $shown;
|
|
||||||
?>
|
|
||||||
<?php $n = 0; foreach ($cellEvents as $ev): $n++; ?>
|
|
||||||
<?php if ($n <= $shown): ?>
|
|
||||||
<?php
|
|
||||||
$evKind = (string)($ev['kind'] ?? 'custom');
|
|
||||||
$evRef = (int)($ev['ref_id'] ?? 0);
|
|
||||||
$evEdit = '';
|
|
||||||
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
|
|
||||||
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
|
|
||||||
?>
|
|
||||||
<a class="cal-ev<?= empty($ev['all_day']) ? '' : ' is-allday' ?>"
|
|
||||||
data-cal="<?= (int)$ev['cal'] ?>"
|
|
||||||
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
|
|
||||||
style="background: <?= e((string)$ev['color']) ?>;"
|
|
||||||
href="<?= e((string)$ev['href']) ?>"
|
|
||||||
title="<?= e((string)$ev['title']) ?>">
|
|
||||||
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b> <?php endif; ?>
|
|
||||||
<?= e((string)$ev['title']) ?>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if ($hiddenCnt > 0): ?>
|
|
||||||
<?php /* 点开 = 当天全部条目(含被折叠的),一律走全站的条目详情浮层;
|
|
||||||
每条都带 data-fb-entry,所以在浮层里还能继续点进详情/编辑/删除。
|
|
||||||
⚠️ 用一个 `<a href>` 兜住无 JS 的情况:直接跳那天的日视图
|
|
||||||
(日视图按时间网格铺开,不会再截断)。 */ ?>
|
|
||||||
<button type="button" class="cal-more"
|
|
||||||
data-cal-day="<?= e((string)$cell['date']) ?>"
|
|
||||||
aria-label="看这一天全部 <?= count($cellEvents) ?> 条">+<?= $hiddenCnt ?> 条</button>
|
|
||||||
<?php /* 浮层用的全部条目数据(服务端渲染好,点开时不需要再请求)。
|
|
||||||
放在格子外面(display:none 的 script 里)—— 塞进格子会撑高月网格。 */ ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -538,34 +308,55 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
(它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */
|
(它在该 IIFE 外面,看不到闭包里的 openNew)⇒ 暴露一个窗口入口。 */
|
||||||
window.fbCalOpenNew = openNew;
|
window.fbCalOpenNew = openNew;
|
||||||
|
|
||||||
/* 月视图格子 */
|
/* ============================================================
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.cal-cell[data-new-at]'), function (cell) {
|
「点格子 / 点列空白 = 在那天(那个时刻)新建日程」
|
||||||
cell.addEventListener('click', function (ev) {
|
------------------------------------------------------------
|
||||||
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
|
🔵 工单 #37:**改成 document 上的事件委托**,不再逐元素绑定。
|
||||||
if (ev.target.closest && ev.target.closest('.cal-ev')) { return; }
|
|
||||||
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
|
|
||||||
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
|
|
||||||
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层叠在一起。 */
|
|
||||||
if (ev.target.closest && ev.target.closest('.cal-more')) { return; }
|
|
||||||
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
|
|
||||||
openNew(cell.getAttribute('data-new-at'), '09', '00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* 周/日视图的列:纵向位置 → 时刻。
|
为什么必须改:原来是 `querySelectorAll(...).forEach(el => addEventListener(...))`,
|
||||||
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
|
只对"渲染那一刻存在"的节点生效。而本工单让视图区可以整块重绘(新建/修改后
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.tg-col[data-new-at]'), function (col) {
|
取服务端片段替换),重绘后换进来的新格子**没人绑** ⇒ 点一下没反应。
|
||||||
col.addEventListener('click', function (ev) {
|
委托之后一根监听管全部(含以后换进来的),重绘也不需要重新绑定。
|
||||||
if (ev.target.closest && ev.target.closest('.tg-block')) { return; }
|
|
||||||
if (!<?= $canWrite && $writable ? 'true' : 'false' ?>) { return; }
|
⚠️ 顺序有意义:先判 `.cal-ev` / `.tg-block`(点条目 = 打开那条,不抢),
|
||||||
var box = col.getBoundingClientRect();
|
再判 `.cal-more`(点 +N 条 = 看清单,不新建),最后才落到"新建"。
|
||||||
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
|
============================================================ */
|
||||||
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
|
(function () {
|
||||||
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
|
/* 服务端就把"能不能写"烘进这段脚本 —— 只读账号点了不该有反应 */
|
||||||
var mm = String(mins % 60).padStart(2, '0');
|
var CAN_WRITE = <?= $canWrite && $writable ? 'true' : 'false' ?>;
|
||||||
openNew(col.getAttribute('data-new-at'), hh, mm);
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var t = ev.target;
|
||||||
|
if (!t || !t.closest) { return; }
|
||||||
|
if (!CAN_WRITE) { return; }
|
||||||
|
|
||||||
|
/* ① 月视图格子:一格 = 一天 → 09:00 起("当日默认"比 00:00 更像个日程) */
|
||||||
|
var cell = t.closest('.cal-cell[data-new-at]');
|
||||||
|
if (cell) {
|
||||||
|
// 点在小条上 = 想打开那条日程(<a> 的默认行为),不要抢
|
||||||
|
if (t.closest('.cal-ev')) { return; }
|
||||||
|
/* 🔴 v70:点「+N 条」= 要看**当天的全部条目**,不是要新建日程。
|
||||||
|
⚠️ 必须显式排除:它是个 <button>,不排除的话会**同时**触发
|
||||||
|
"新建日程浮层" 与 "当天全部条目浮层" 两个浮层。 */
|
||||||
|
if (t.closest('.cal-more')) { return; }
|
||||||
|
openNew(cell.getAttribute('data-new-at'), '09', '00');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ② 周/日视图的列:按鼠标在**那一列里的纵向位置**反算时刻,最接近半小时。
|
||||||
|
列高 1056px = 24 小时(见 .tg-col 的 CSS),所以 1 分钟 = 1056/1440 px。 */
|
||||||
|
var col = t.closest('.tg-col[data-new-at]');
|
||||||
|
if (col) {
|
||||||
|
if (t.closest('.tg-block')) { return; }
|
||||||
|
var box = col.getBoundingClientRect();
|
||||||
|
var ratio = (ev.clientY - box.top) / Math.max(1, box.height);
|
||||||
|
var mins = Math.max(0, Math.min(24 * 60 - 30, Math.round(ratio * 1440 / 30) * 30));
|
||||||
|
var hh = String(Math.floor(mins / 60)).padStart(2, '0');
|
||||||
|
var mm = String(mins % 60).padStart(2, '0');
|
||||||
|
openNew(col.getAttribute('data-new-at'), hh, mm);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
})();
|
||||||
|
|
||||||
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
|
/* 从 ?new_at= 进来(比如别处链过来)→ 直接开弹窗并预填 */
|
||||||
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
|
var preset = <?= json_encode($newAt, JSON_UNESCAPED_UNICODE) ?>;
|
||||||
@@ -626,268 +417,22 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
|
if (tip) { tip.textContent = msg; tip.className = 'cal-modal-tip err'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把一条 `fb_entry_json` 形态的条目补进 `#calDayData`(按天分组,幂等)。
|
/* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
|
||||||
* ⚠️ 只补**还没在里面**的那条(同 ref + 同 occ 视为同一条)——
|
而是使用js动态调整。」
|
||||||
* 否则连点两次「加上」或前端重放会把同一条塞两遍,清单里出现两条一样的。 */
|
|
||||||
function pushDayData(day, entry) {
|
|
||||||
var host = document.getElementById('calDayData');
|
|
||||||
if (!host || !day || !entry) { return; }
|
|
||||||
var all;
|
|
||||||
try { all = JSON.parse(host.textContent || '{}') || {}; } catch (e) { return; }
|
|
||||||
var list = all[day] || [];
|
|
||||||
var same = false;
|
|
||||||
for (var i = 0; i < list.length; i++) {
|
|
||||||
if (String(list[i].ref || '') === String(entry.ref || '')
|
|
||||||
&& String(list[i].occ || '') === String(entry.occ || '')
|
|
||||||
&& String(list[i].title || '') === String(entry.title || '')) { same = true; break; }
|
|
||||||
}
|
|
||||||
if (!same) {
|
|
||||||
/* 位置按服务端那套 `displayRank + 时间` 插(派生条目垫底),
|
|
||||||
免得清单里的顺序与刷新后的顺序不一致。 */
|
|
||||||
var rk = function (o) {
|
|
||||||
var s = String(o.source || '');
|
|
||||||
return s === 'event' ? 0 : 4; /* 派生条目一律 4,与服务端 displayRank 的兜底一致 */
|
|
||||||
};
|
|
||||||
var tm = function (o) {
|
|
||||||
var rows = o.rows || [];
|
|
||||||
for (var k = 0; k < rows.length; k++) {
|
|
||||||
if (rows[k][0] === '时间') {
|
|
||||||
var v = String(rows[k][1] || '');
|
|
||||||
/* "YYYY-MM-DD HH:MM–HH:MM" / "YYYY-MM-DD 全天" → 取时刻部分比较 */
|
|
||||||
var seg = v.slice(day.length).trim();
|
|
||||||
return seg === '全天' ? '' : seg.slice(0, 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
var pos = list.length;
|
|
||||||
for (var j = 0; j < list.length; j++) {
|
|
||||||
if (rk(list[j]) > rk(entry)) { pos = j; break; }
|
|
||||||
if (rk(list[j]) === rk(entry) && tm(list[j]) > tm(entry)) { pos = j; break; }
|
|
||||||
}
|
|
||||||
list.splice(pos, 0, entry);
|
|
||||||
all[day] = list;
|
|
||||||
try { host.textContent = JSON.stringify(all); } catch (e2) { /* 序列化失败就别动 */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 月视图:往 data-new-at="YYYY-MM-DD" 那个格子里塞一条小条 */
|
这里**原来有一整套手写复刻服务端渲染的代码**(`pushDayData` / `putMonth` /
|
||||||
function putMonth(ev) {
|
`relayoutColumn` / `putTimed` / `insert`,约 270 行):月视图往格子里塞小条
|
||||||
var day = String(ev.starts_at || '').slice(0, 10);
|
并按时间插到正确位置、周/日视图按"24h=100%"算 top/height、插完重算整列分栏…
|
||||||
var cell = document.querySelector('.cal-cell[data-new-at="' + day + '"]');
|
|
||||||
if (!cell) { return false; }
|
|
||||||
var box = cell.querySelector('.cal-evs');
|
|
||||||
if (!box) { return false; }
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'cal-ev' + (ev.all_day ? ' is-allday' : '');
|
|
||||||
a.setAttribute('data-cal', String(ev.cal || ''));
|
|
||||||
a.style.background = ev.color || '';
|
|
||||||
a.setAttribute('href', ev.href || '#');
|
|
||||||
a.setAttribute('title', ev.title || '');
|
|
||||||
/* ⚠️ 时间前缀的形状必须与服务端一致(<b>HH:MM</b> 后面一个空格)——
|
|
||||||
不一致的话刷新一次就会看出差别。 */
|
|
||||||
if (ev.time) {
|
|
||||||
var b = document.createElement('b');
|
|
||||||
b.textContent = ev.time;
|
|
||||||
a.appendChild(b);
|
|
||||||
a.appendChild(document.createTextNode(' '));
|
|
||||||
}
|
|
||||||
a.appendChild(document.createTextNode(ev.title || ''));
|
|
||||||
/* 🔴 插到**正确的时间位置**,不能 append 到末尾。
|
|
||||||
为什么必须按时间插:服务端只渲染前 3 条(第 4 条起收进"+N 条"),
|
|
||||||
而"哪 3 条"是按**时间顺序**取的。若这边一律 append:
|
|
||||||
· 新加一条**早上 7 点**的日程,它会被排到最后 → 落到"+N 条"里看不见,
|
|
||||||
可刷新一次又会跑到第一位 —— 这正是"插进去和刷新后长得不一样"。
|
|
||||||
⚠️ 判据与服务端渲染同一套:先比 <b>HH:MM</b>,没有时间前缀的(全天)排前面
|
|
||||||
(与 Calendar::month 给 all_day 的处理一致),再比标题兜底稳定性。 */
|
|
||||||
var newTime = ev.time || '';
|
|
||||||
var placed = false;
|
|
||||||
var existing = Array.prototype.slice.call(box.querySelectorAll('.cal-ev'));
|
|
||||||
for (var k = 0; k < existing.length; k++) {
|
|
||||||
var tEl = existing[k].querySelector('b');
|
|
||||||
var tTxt = tEl ? tEl.textContent.trim() : '';
|
|
||||||
if (tTxt === '' && newTime !== '') { continue; } /* 空时间(全天)在前 */
|
|
||||||
if (tTxt !== '' && newTime === '') { box.insertBefore(a, existing[k]); placed = true; break; }
|
|
||||||
if (tTxt > newTime) { box.insertBefore(a, existing[k]); placed = true; break; }
|
|
||||||
}
|
|
||||||
if (!placed) { box.appendChild(a); }
|
|
||||||
|
|
||||||
/* 再按"只显示前 3 条"收口,与服务端渲染对齐(否则会多出一条)。 */
|
它自己的注释里就记着一次真实事故:新加一条 07:00 的日程被 append 到末尾 ⇒
|
||||||
var links = box.querySelectorAll('.cal-ev');
|
落进「+N 条」看不见,可刷新一次又跑到第一位。
|
||||||
var more = box.querySelector('.cal-more');
|
这类 bug 的根子是"两处渲染必须同步",只要两处存在就一定会有偏差。
|
||||||
/* 🔴 v70:新条目同步进 `#calDayData` —— 否则「+N 条」点开是**旧**清单,
|
|
||||||
刚建的这条不在里面(刷新一次又有了)。放在收口之前做,跟服务端渲染时
|
|
||||||
"先把当天全部条目收进数据岛、再只画前 3 条"的顺序一致。 */
|
|
||||||
if (ev.entry) { pushDayData(day, ev.entry); }
|
|
||||||
for (var j = 0; j < links.length; j++) {
|
|
||||||
links[j].style.display = (j < 3) ? '' : 'none';
|
|
||||||
}
|
|
||||||
if (links.length > 3) {
|
|
||||||
var extra = links.length - 3;
|
|
||||||
if (!more) {
|
|
||||||
/* 🔴 v70(用户 2026-10-20 第 2 条):「+N 条」原来是颗 `<span>`,
|
|
||||||
**点不动** ⇒ 被它折叠掉的条目永远看不到("导进去了但不显示")。
|
|
||||||
这里必须造**按钮**并带 `data-cal-day`(与模板渲染同形),
|
|
||||||
否则新建一条之后,这个格子就退回"点不动"。 */
|
|
||||||
more = document.createElement('button');
|
|
||||||
more.type = 'button';
|
|
||||||
more.className = 'cal-more';
|
|
||||||
box.appendChild(more);
|
|
||||||
}
|
|
||||||
more.setAttribute('data-cal-day', day);
|
|
||||||
more.setAttribute('aria-label', '看这一天全部 ' + links.length + ' 条');
|
|
||||||
more.textContent = '+' + extra + ' 条';
|
|
||||||
} else if (more) {
|
|
||||||
more.parentNode.removeChild(more);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 🔴🔴 v51(用户 2026-10-15 第 3 条):「添加一个日程之后,只是把这个日程放在了
|
⇒ **整段删掉**,改用 `assets/js/cal_sync.js` 的 `refresh()`:
|
||||||
周视图或者是日视图上面。如果它这个时间段已经有日程了,它会**直接盖在上面**,
|
新建成功后取一份服务端渲染好的视图片段整块替换。
|
||||||
却并没有按照**分栏**的方式左右显示。这个需要再调整一下。」
|
这样"加进去的样子"与"刷新一次的样子"**必然**相同,因为只有一处渲染。
|
||||||
|
日历页新建 / 首页新建 / 浮层改完,三条路走同一个函数。
|
||||||
真根因:`putTimed()` 只把自己那条色块按 top/height **整宽铺上去**,
|
*/
|
||||||
完全不知道同一列里已有的日程 —— 于是两条重叠时后加的直接压在先有的上面
|
|
||||||
(服务端渲染时是有分栏的:`App\Calendar::conflictLanes`,但那是**整页重绘**时才算的)。
|
|
||||||
|
|
||||||
⇒ 修法:**在浏览器里重算整列的分栏**(与服务端同一套算法),插入后重排该列。
|
|
||||||
为什么"重算整列"而不是"给新块腾个位置":
|
|
||||||
新日程可能同时与**多条**已有日程冲突(比如插进一个 3 段重叠的时段),
|
|
||||||
只挪新块会让已有多条的宽度/位置也不对(它们现在是 2 列、重算后该是 3 列)
|
|
||||||
—— 必须让整列一起重排才是自洽的。
|
|
||||||
⚠️ 算法**照抄** app/Calendar.php 的 conflictLanes(贪心分列 + 分组复列):
|
|
||||||
两边算错一个边界,刷新一次位置就会跳。
|
|
||||||
⚠️ 高度仍按各自"声明的起止"算(没结束的用 +30′)—— 与服务端那套一致。
|
|
||||||
(服务端在"没填结束"时还会拿同列下一条的起点当视觉结束;这里简化成 +30′,
|
|
||||||
差别只在"未填结束"这一种情况,且插入后用户一刷新就归位。 */
|
|
||||||
function minsAttr(el, attr) {
|
|
||||||
var v = el.getAttribute(attr);
|
|
||||||
if (v === null || v === '') { return null; }
|
|
||||||
var n = parseInt(v, 10);
|
|
||||||
return isNaN(n) ? null : n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重算某一列里**所有** .tg-block 的分栏(left/width)与 top/height */
|
|
||||||
function relayoutColumn(col) {
|
|
||||||
var blocks = Array.prototype.slice.call(col.querySelectorAll('.tg-block'));
|
|
||||||
if (!blocks.length) { return; }
|
|
||||||
// ① 归一成 [{el, s, e}](缺 data-e → +30′;缺 data-s → 从 top 反推兜底)
|
|
||||||
var rows = [];
|
|
||||||
blocks.forEach(function (el) {
|
|
||||||
var s = minsAttr(el, 'data-s');
|
|
||||||
if (s === null) {
|
|
||||||
var tp = parseFloat(el.style.top) || 0;
|
|
||||||
s = Math.round(tp / 100 * 1440);
|
|
||||||
}
|
|
||||||
var e = minsAttr(el, 'data-e');
|
|
||||||
if (e === null || e <= s) { e = s + 30; }
|
|
||||||
rows.push({ el: el, s: s, e: Math.min(e, 1440) });
|
|
||||||
});
|
|
||||||
// ② 按开始时间升序(同开始则长的在前)—— 与服务端 usort 同序
|
|
||||||
rows.sort(function (a, b) { return a.s - b.s || b.e - a.e; });
|
|
||||||
// ③ 划分冲突组(与组内最晚结束仍重叠就并组)
|
|
||||||
var groups = [], cur = [], endMax = -1;
|
|
||||||
rows.forEach(function (r) {
|
|
||||||
if (cur.length && r.s >= endMax) { groups.push(cur); cur = []; endMax = -1; }
|
|
||||||
cur.push(r);
|
|
||||||
endMax = Math.max(endMax, r.e);
|
|
||||||
});
|
|
||||||
if (cur.length) { groups.push(cur); }
|
|
||||||
// ④ 组内贪心分列(每列记最后结束分钟,放进编号最小的可用列)
|
|
||||||
groups.forEach(function (g) {
|
|
||||||
var laneEnd = [], laneOf = [];
|
|
||||||
g.forEach(function (r, k) {
|
|
||||||
var lane = -1;
|
|
||||||
for (var li = 0; li < laneEnd.length; li++) {
|
|
||||||
if (laneEnd[li] <= r.s) { lane = li; break; }
|
|
||||||
}
|
|
||||||
if (lane < 0) { lane = laneEnd.length; laneEnd.push(r.e); }
|
|
||||||
else { laneEnd[lane] = r.e; }
|
|
||||||
laneOf[k] = lane;
|
|
||||||
});
|
|
||||||
var cols = Math.max(1, laneEnd.length);
|
|
||||||
g.forEach(function (r, k) {
|
|
||||||
var left = laneOf[k] / cols * 100, width = 100 / cols;
|
|
||||||
r.el.style.left = 'calc(2px + ' + left.toFixed(4) + '%)';
|
|
||||||
r.el.style.width = 'calc(' + width.toFixed(4) + '% - 4px)';
|
|
||||||
r.el.style.top = (r.s / 1440 * 100).toFixed(4) + '%';
|
|
||||||
r.el.style.height = ((Math.max(15, r.e - r.s)) / 1440 * 100).toFixed(4) + '%';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
|
|
||||||
function putTimed(ev) {
|
|
||||||
if (ev.all_day) {
|
|
||||||
var ad = document.querySelector('.tg-adcell');
|
|
||||||
if (!ad) { return false; }
|
|
||||||
var box2 = document.querySelector('.tg-allday');
|
|
||||||
var col = null;
|
|
||||||
if (box2) {
|
|
||||||
/* 找到"那一天"那一列:列的顺序与 .tg-dayname 一致,
|
|
||||||
用 data-new-at 反查(服务端给每列写了当天日期)。 */
|
|
||||||
var cols = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
|
|
||||||
var day2 = String(ev.starts_at || '').slice(0, 10);
|
|
||||||
var idx = -1;
|
|
||||||
cols.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day2) { idx = i; } });
|
|
||||||
if (idx >= 0) {
|
|
||||||
var cells = document.querySelectorAll('.tg-adcell');
|
|
||||||
col = cells[idx] || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var host = col || ad;
|
|
||||||
if (!host) { return false; }
|
|
||||||
var a2 = document.createElement('a');
|
|
||||||
a2.className = 'cal-ev is-allday';
|
|
||||||
a2.setAttribute('data-cal', String(ev.cal || ''));
|
|
||||||
a2.style.background = ev.color || '';
|
|
||||||
a2.setAttribute('href', ev.href || '#');
|
|
||||||
a2.setAttribute('title', ev.title || '');
|
|
||||||
a2.textContent = ev.title || '';
|
|
||||||
host.appendChild(a2);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
var cols3 = Array.prototype.slice.call(document.querySelectorAll('.tg-col[data-new-at]'));
|
|
||||||
var day3 = String(ev.starts_at || '').slice(0, 10);
|
|
||||||
var ci = -1;
|
|
||||||
cols3.forEach(function (c, i) { if (c.getAttribute('data-new-at') === day3) { ci = i; } });
|
|
||||||
if (ci < 0) { return false; }
|
|
||||||
var target = cols3[ci];
|
|
||||||
var minsOf = function (t) {
|
|
||||||
var m = String(t || '').match(/[ T](\d{1,2}):(\d{1,2})/);
|
|
||||||
return m ? (+m[1]) * 60 + (+m[2]) : 0;
|
|
||||||
};
|
|
||||||
var st = minsOf(ev.starts_at), en = minsOf(ev.ends_at);
|
|
||||||
if (en <= st) { en = st + 30; } /* 没填结束就给半小时 —— 与服务端一致 */
|
|
||||||
var top = (st / 1440) * 100, hgt = ((en - st) / 1440) * 100;
|
|
||||||
var blk = document.createElement('a');
|
|
||||||
blk.className = 'tg-block';
|
|
||||||
blk.setAttribute('data-cal', String(ev.cal || ''));
|
|
||||||
/* 🔴 v51:带上"声明的起止分钟",重算分栏要用(服务端渲染的同名属性)。
|
|
||||||
⚠️ 与服务端口径一致:结束缺失/倒挂 → 不带 data-e(按 +30′ 处理)。 */
|
|
||||||
blk.setAttribute('data-s', String(st));
|
|
||||||
var declE = minsOf(ev.ends_at);
|
|
||||||
if (declE > st) { blk.setAttribute('data-e', String(declE)); }
|
|
||||||
blk.style.background = ev.color || '';
|
|
||||||
blk.style.top = top.toFixed(4) + '%';
|
|
||||||
blk.style.height = hgt.toFixed(4) + '%';
|
|
||||||
blk.setAttribute('href', ev.href || '#');
|
|
||||||
blk.setAttribute('title', ev.title || '');
|
|
||||||
blk.textContent = ev.title || '';
|
|
||||||
target.appendChild(blk);
|
|
||||||
/* 🔴 v51(第 3 条):插完**重排整列** —— 否则这条会整宽盖在已有日程上(用户报的现象)。 */
|
|
||||||
relayoutColumn(target);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function insert(ev) {
|
|
||||||
/* 先看当前视图是哪一种:页面上有 .cal-grid 就是月视图;
|
|
||||||
有 .tg-col 就是周/日视图。两者都不在(理论上不会)→ 退回刷新。 */
|
|
||||||
if (document.querySelector('.cal-grid')) { return putMonth(ev); }
|
|
||||||
if (document.querySelector('.tg-col[data-new-at]')) { return putTimed(ev); }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
if (form.getAttribute('data-native-submit') === '1') { return; }
|
if (form.getAttribute('data-native-submit') === '1') { return; }
|
||||||
@@ -903,38 +448,50 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (sub) { sub.disabled = false; }
|
if (sub) { sub.disabled = false; }
|
||||||
if (d && d.ok && d.event) {
|
if (d && d.ok && d.event) {
|
||||||
var ok = false;
|
/* 🔵 工单 #37:不再手写插 DOM,改为让 cal_sync 取片段重绘。
|
||||||
try { ok = insert(d.event); } catch (err) { ok = false; }
|
`d.msg` 里带着服务端那句人话(含"已加进观影/旅程"这类说明)。
|
||||||
if (ok) {
|
⚠️ 观影/旅程本是**派生**的:服务端给了 `goto` 时反而**不能**
|
||||||
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
|
立刻重绘收提示 —— 用户可能想点那个"去看详情"。所以这两条路分开:
|
||||||
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
|
有 goto(派生本)→ 保留提示行 + 链接,重绘照做;
|
||||||
if (d.goto && tip) {
|
没有 goto(普通日程)→ 关浮层 + 轻提示 + 重绘。 */
|
||||||
tip.className = 'cal-modal-tip';
|
var sync = window.fbCalSync;
|
||||||
tip.textContent = (d.msg || '已加上。') + ' ';
|
if (!sync || typeof sync.refresh !== 'function') {
|
||||||
var ga = document.createElement('a');
|
/* 老缓存没加载到脚本 → 退回原生提交,别假装成功 */
|
||||||
ga.href = d.goto;
|
form.setAttribute('data-native-submit', '1');
|
||||||
ga.textContent = '去看详情 →';
|
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
||||||
tip.appendChild(ga);
|
else { form.submit(); }
|
||||||
setTimeout(function () {
|
|
||||||
if (tip) { tip.textContent = ''; }
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
closeNew();
|
|
||||||
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
|
|
||||||
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
|
|
||||||
var t = document.getElementById('ev-title');
|
|
||||||
var lo = document.getElementById('ev-location');
|
|
||||||
var no = document.getElementById('ev-note');
|
|
||||||
if (t) { t.value = ''; }
|
|
||||||
if (lo) { lo.value = ''; }
|
|
||||||
if (no) { no.value = ''; }
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
/* 插入不成功 = "没刷新却也没看到" → 宁可刷新一次,别假装成功 */
|
/* 观影 / 旅行本是**派生**的(内容来自观影/旅程表):AJAX 时服务端
|
||||||
form.setAttribute('data-native-submit', '1');
|
不再自动跳详情页(跳走就离开了日历),改在提示行给一个链接。 */
|
||||||
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
|
if (d.goto && tip) {
|
||||||
else { form.submit(); }
|
tip.className = 'cal-modal-tip';
|
||||||
|
tip.textContent = (d.msg || '已加上。') + ' ';
|
||||||
|
var ga = document.createElement('a');
|
||||||
|
ga.href = d.goto;
|
||||||
|
ga.textContent = '去看详情 →';
|
||||||
|
tip.appendChild(ga);
|
||||||
|
setTimeout(function () {
|
||||||
|
if (tip) { tip.textContent = ''; }
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
closeNew();
|
||||||
|
/* 重置"只填一次的字段",但**保留**日历本的选择(用户往往连加几条)。
|
||||||
|
⚠️ 标题必须清掉,否则下一条会带着上一条的标题(很容易漏掉)。 */
|
||||||
|
var t = document.getElementById('ev-title');
|
||||||
|
var lo = document.getElementById('ev-location');
|
||||||
|
var no = document.getElementById('ev-note');
|
||||||
|
if (t) { t.value = ''; }
|
||||||
|
if (lo) { lo.value = ''; }
|
||||||
|
if (no) { no.value = ''; }
|
||||||
|
/* 重绘当前视图(不刷新页面)。有 goto 的是派生本,提示留 6 秒让人点链接。 */
|
||||||
|
var opts = { msg: (d.msg || '已加上。') };
|
||||||
|
if (d.goto) {
|
||||||
|
opts.msg = (d.msg || '已加上。') + ' 去看详情 →';
|
||||||
|
}
|
||||||
|
sync.refresh(opts);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
if (d && d.msg) { sayErr(d.msg); return; }
|
if (d && d.msg) { sayErr(d.msg); return; }
|
||||||
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
|
/* 拿不到 JSON:会话过期 / 500 → 退回原生提交,让服务端说话 */
|
||||||
|
|||||||
+48
-1
@@ -196,6 +196,10 @@
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
var now = b.getAttribute('data-tk-status') || 'todo';
|
var now = b.getAttribute('data-tk-status') || 'todo';
|
||||||
var next = (now === 'done') ? 'todo' : 'done';
|
var next = (now === 'done') ? 'todo' : 'done';
|
||||||
|
/* 🔴 工单 #20:先把"改之前的状态"存下来 —— 后面删行时要用它决定
|
||||||
|
「概述」卡里该减「件在做」还是「件待办」。
|
||||||
|
⚠️ 不能在删行时再读 `data-tk-status`:那时它已经被下面改成 `d.status` 了。 */
|
||||||
|
var was = now;
|
||||||
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
|
document.getElementById('doneId').value = b.getAttribute('data-tk-done');
|
||||||
document.getElementById('doneStatus').value = next;
|
document.getElementById('doneStatus').value = next;
|
||||||
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
|
document.getElementById('doneBack').value = b.getAttribute('data-tk-back') || 'home';
|
||||||
@@ -217,10 +221,53 @@
|
|||||||
badge.className = 'kb-st is-' + d.status + ' is-static';
|
badge.className = 'kb-st is-' + d.status + ' is-static';
|
||||||
badge.textContent = staticWord[d.status] || d.word;
|
badge.textContent = staticWord[d.status] || d.word;
|
||||||
}
|
}
|
||||||
say(d.msg || '已更新。', true);
|
/* 🔴🔴 工单 #20(用户 2026-10-28):「首页,近期任务,如果勾选了完成,
|
||||||
|
应该刷新一下这个列表,把完成的删掉。」
|
||||||
|
⇒ 在这里把这一行**从列表里摘掉**,并同步概述卡的计数。
|
||||||
|
⚠️ 判据是**服务端返回的 d.status**,不是我点之前算出的 next:
|
||||||
|
`tasks/status` 是幂等接口(传 done 再来一次还是 done),
|
||||||
|
拿 d.status 判断才不会"用户手快连点两下,第二下又把行删了"。
|
||||||
|
⚠️ 首页这份列表只收 doing + todo(index.php:839),done 本来就不在其中
|
||||||
|
⇒ 手动刷新时它确实会消失,所以"删行"与刷新后的结果**一致**,
|
||||||
|
不是前端自己造了个假象。
|
||||||
|
⚠️ 当年这里是刻意**不删**的(原注释:怕用户以为"点到删除了")——
|
||||||
|
用户现在明确要它消失,所以改掉。
|
||||||
|
🔴 补偿方式是**一句说明去哪了**(下面 say() 那段),**不是"撤回按钮"**:
|
||||||
|
先前这里的注释写着"补偿了一个可撤回的提示",但代码里从来没有撤回按钮,
|
||||||
|
只有一句 3.2 秒自动消失的 `say()`。
|
||||||
|
注释描述不存在的功能,比没有注释更糟 —— 下一个人会去找它、找不到、然后再写一个。
|
||||||
|
(撤回的正路:卡片右上角「全部」→ 任务看板,那里 done 的还在,能改回来。) */
|
||||||
|
if (d.status === 'done' && row) {
|
||||||
|
dropTaskRow(row, was);
|
||||||
|
/* 🔴 工单 #20:行被摘掉了,必须**明说**它去哪了 ——
|
||||||
|
当年"不删行"的理由就是"怕用户以为点到删除了"(见上面原注释)。
|
||||||
|
删了行就要把这条路补上:告诉他已从近期任务移除,
|
||||||
|
而卡片右上角本来就有「全部」能找回($hcMore → tasks)。
|
||||||
|
⚠️ 不能只沿用服务端那句 `已标成「已完成」` —— 那会让人以为行还在,
|
||||||
|
下一眼发现没了反而更慌。 */
|
||||||
|
say((d.msg || '已标成「已完成」。') + '(已从近期任务移出,去「全部」里能看到)', true);
|
||||||
|
} else {
|
||||||
|
say(d.msg || '已更新。', true);
|
||||||
|
}
|
||||||
}).catch(function () { b.disabled = false; form.submit(); });
|
}).catch(function () { b.disabled = false; form.submit(); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
把一条刚完成的任务行从首页列表里摘掉(工单 #20)
|
||||||
|
============================================================
|
||||||
|
🔴🔴 实现在 `assets/js/app.js` 的 `window.fbHomeTaskDrop()` —— 这里只是转发。
|
||||||
|
**不要**把那段逻辑抄回来:同一个动作在首页有两个入口
|
||||||
|
(① 这颗复选框 ② 点整行弹浮层、在面板里点「已完成」,
|
||||||
|
脚本是 task_panel.js),抄两份必然只改一份。
|
||||||
|
第一版就是抄在本地,于是"从复选框勾会删行、从面板点不删",
|
||||||
|
用户复现了后者,而我的判据只测前者 ⇒ 20/0 全绿也照样是假绿。
|
||||||
|
⚠️ 保留这个薄封装是因为下面两处调用都读 `was`,就地传更清楚;
|
||||||
|
同时也让 home.php 里"删行"这件事还有一处可 grep 的落点。 */
|
||||||
|
function dropTaskRow(row, wasStatus) {
|
||||||
|
if (typeof window.fbHomeTaskDrop !== 'function') { return; }
|
||||||
|
window.fbHomeTaskDrop(row, wasStatus);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ foreach (($tasks ?? []) as $__t) {
|
|||||||
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
|
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
|
||||||
</a>
|
</a>
|
||||||
<?php /* 两个数字与右侧「近期任务」卡片**同源**(同一个 $tasks 数组),
|
<?php /* 两个数字与右侧「近期任务」卡片**同源**(同一个 $tasks 数组),
|
||||||
「在做」与「待办」按状态拆开数 —— 见 v53 第 2 条那条注释。 */ ?>
|
「在做」与「待办」按状态拆开数 —— 见 v53 第 2 条那条注释。
|
||||||
<a class="hbs" href="<?= e(url('tasks')) ?>" title="近期任务(进行中)">
|
🔴 工单 #20:`data-hbs` 是给首页脚本定位用的 —— 勾完成一条任务后,
|
||||||
|
这两个数字要跟着减 1(否则与列表自相矛盾)。
|
||||||
|
⚠️ 不能让脚本去匹配中文「件在做」:文案改一个字就失效,
|
||||||
|
而且这里是 JS 会读的钩子,写成文案匹配等于把展示文案变成接口。 */ ?>
|
||||||
|
<a class="hbs" data-hbs="doing" href="<?= e(url('tasks')) ?>" title="近期任务(进行中)">
|
||||||
<b><?= $nDoing ?></b> 件在做
|
<b><?= $nDoing ?></b> 件在做
|
||||||
</a>
|
</a>
|
||||||
<?php if ($nTodo > 0): ?>
|
<?php if ($nTodo > 0): ?>
|
||||||
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
|
<a class="hbs" data-hbs="todo" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
|
||||||
<b><?= $nTodo ?></b> 件待办
|
<b><?= $nTodo ?></b> 件待办
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -20,10 +20,23 @@
|
|||||||
* 面板取不到(404 / 网络)时,浮层会退回 `rows` 那份只读信息 —— 不会白屏。
|
* 面板取不到(404 / 网络)时,浮层会退回 `rows` 那份只读信息 —— 不会白屏。
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="hc-body">
|
<div class="hc-body" data-tk-home-host>
|
||||||
<?php if (!$tasks): ?>
|
<?php /* 🔴 工单 #20(用户 2026-10-28):「首页,近期任务,如果勾选了完成,
|
||||||
<p class="empty-hint">没有进行中的任务,也没有待办。</p>
|
应该刷新一下这个列表,把完成的删掉。」
|
||||||
<?php else: ?>
|
⇒ 勾完要把这一行**从列表里摘掉**(原来只改徽章文字,见 home.php 那段脚本)。
|
||||||
|
⚠️ 但"删到一条不剩"时卡里不能是一片空白 —— 下面这句 `empty-hint` 由服务端渲染,
|
||||||
|
前端删完不会自己冒出来,所以脚本要能在空的时候把它补上。
|
||||||
|
`data-tk-empty` 就是给脚本找它的钩子(不靠文案匹配,文案改一个字就失效)。
|
||||||
|
|
||||||
|
🔴🔴 把任务标成完成在首页有**两个入口**,两个都要删行:
|
||||||
|
① 每行前面那颗复选框 → 脚本在 `home.php` 末尾
|
||||||
|
② **点整行弹浮层** → 面板里的「状态」三段按钮 → `assets/js/task_panel.js`
|
||||||
|
真正干活的是 `assets/js/app.js` 的 `window.fbHomeTaskDrop()`,两个入口共用。
|
||||||
|
⚠️ 第一版只改了 ①,用户点的是 ②,于是"改完还是不消失"——
|
||||||
|
而我的判据只测了 ①,所以 20/0 全绿也是假绿。
|
||||||
|
**需求有几个入口,就要有几份判据。** */ ?>
|
||||||
|
<p class="empty-hint" data-tk-empty<?= $tasks ? ' hidden' : '' ?>>没有进行中的任务,也没有待办。</p>
|
||||||
|
<?php if ($tasks): ?>
|
||||||
<ul class="dash-list tk-list">
|
<ul class="dash-list tk-list">
|
||||||
<?php foreach ($tasks as $t): ?>
|
<?php foreach ($tasks as $t): ?>
|
||||||
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
|
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
|
||||||
@@ -43,6 +56,24 @@
|
|||||||
'title' => (string)$t['title'],
|
'title' => (string)$t['title'],
|
||||||
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
|
||||||
'note' => (string)($t['note'] ?? ''),
|
'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:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */
|
/* v78:浮层把正文换成这块面板(可就地改状态/看板/子任务/附件/评论) */
|
||||||
'panel' => url('tasks/panel', ['id' => (int)$t['id']]),
|
'panel' => url('tasks/panel', ['id' => (int)$t['id']]),
|
||||||
], url('tasks/show', ['id' => (int)$t['id']]),
|
], url('tasks/show', ['id' => (int)$t['id']]),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="auth-wrap">
|
<div class="auth-wrap">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
||||||
<h1>安装「家书」</h1>
|
<h1>安装「岁月家」</h1>
|
||||||
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
|
<p class="lead">先建好你的家庭空间,再给家里人开通知。填完这一步就能用了。</p>
|
||||||
|
|
||||||
<?php if (!empty($errors['_'])): ?>
|
<?php if (!empty($errors['_'])): ?>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<label for="family_name">家庭名称</label>
|
<label for="family_name">家庭名称</label>
|
||||||
<input class="input" id="family_name" name="family_name"
|
<input class="input" id="family_name" name="family_name"
|
||||||
value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required>
|
value="<?= e(old('family_name', '我们家')) ?>" placeholder="比如:我们家" required>
|
||||||
<span class="tip">只存档用 —— 页面上显示的是「某某某的家书」,不会再展示这个名称。</span>
|
<span class="tip">只存档用 —— 页面上显示的是「某某某的家庭岁月」,不会再展示这个名称。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="motto">系统 slogan</label>
|
<label for="motto">系统 slogan</label>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= e($pageTitle) ?> · 家书</title>
|
<title><?= e($pageTitle) ?> · 岁月家</title>
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
|
|||||||
+23
-3
@@ -43,7 +43,7 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
document.documentElement.className.replace('no-js', 'js');</script>
|
document.documentElement.className.replace('no-js', 'js');</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= e($pageTitle) ?> · 家书</title>
|
<title><?= e($pageTitle) ?> · 岁月家</title>
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
@@ -134,16 +134,25 @@ if ($_needAddr): ?>
|
|||||||
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
|
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
|
||||||
⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 ——
|
⚠️ 只覆盖这三个变量,**不碰** --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) ?>"
|
<body class="mod-<?= e($moduleKey) ?>"
|
||||||
data-media-base="media.php?k="
|
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): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-inner">
|
<div class="topbar-inner">
|
||||||
<a class="brand" href="<?= e(url('home')) ?>">
|
<a class="brand" href="<?= e(url('home')) ?>">
|
||||||
<img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt="">
|
<img src="<?= e(asset('/assets/icons/icon-96.png')) ?>" alt="">
|
||||||
<span>家书</span>
|
<span>岁月家</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<?php foreach ($navItems as $key => list($label, $route)): ?>
|
<?php foreach ($navItems as $key => list($label, $route)): ?>
|
||||||
@@ -251,6 +260,17 @@ require __DIR__ . '/_entry_popup.php'; ?>
|
|||||||
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
|
三处入口分散在首页/日程页,按需加载的判据会在这些页面之间来回漏;
|
||||||
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
|
它只有几 KB,不值得为省这点流量赌"某个页面漏加载"。 */ ?>
|
||||||
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
|
<script src="<?= e(asset('/assets/js/entry_popup.js')) ?>" defer></script>
|
||||||
|
<?php /* 🔵 工单 #37(用户 2026-11-01):「修改和新建,都不应该刷新页面,
|
||||||
|
而是使用js动态调整。」
|
||||||
|
⇒ 新建/修改成功后**取服务端渲染好的片段整块替换**,不手写复刻渲染。
|
||||||
|
历史教训:手写复刻必然出错(`templates/calendar/index.php` 里 `putMonth` 的注释记着
|
||||||
|
一次真实事故 —— 新加一条 07:00 的日程被 append 到末尾,落进「+N 条」看不见,
|
||||||
|
刷新后却又跑到第一位)。
|
||||||
|
⚠️ 全站加载:它在**首页**和**日程页**各有落点,按需加载的判据会来回漏;
|
||||||
|
自带"两个落点都不在就什么都不做"的判据,开销可忽略。
|
||||||
|
⚠️ 必须排在 `entry_popup.js` 之后(浮层保存成功后要调它的 refresh)。
|
||||||
|
`defer` 保证按文档顺序执行,所以这两行的先后就是执行先后。 */ ?>
|
||||||
|
<script src="<?= e(asset('/assets/js/cal_sync.js')) ?>" defer></script>
|
||||||
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
|
<?php /* 🔴 v78:首页任务面板的动作脚本(状态/看板/子任务/附件/评论)。
|
||||||
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
|
同样全站加载:面板是浮层的一部分,而浮层本身就挂在本布局里;
|
||||||
脚本自带"面板不在就什么都不做"的判据。 */ ?>
|
脚本自带"面板不在就什么都不做"的判据。 */ ?>
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<div class="auth-head">
|
<div class="auth-head">
|
||||||
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
<img class="logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
|
||||||
<h1><?= e($familyName) ?></h1>
|
<h1><?= e($familyName) ?></h1>
|
||||||
<p class="lead">用手机号登录你的家书</p>
|
<p class="lead">用手机号登录你的岁月家</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($error)): ?>
|
<?php if (!empty($error)): ?>
|
||||||
|
|||||||
+11
-23
@@ -289,7 +289,8 @@ $fbAdd = !empty($fbAddModalAttr);
|
|||||||
* 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。
|
* 按钮留在原位、用 HTML5 的 `form="id"` 关联到外面那个表单。
|
||||||
*/
|
*/
|
||||||
$mHasPoster = $isEdit && !empty($m['poster_key']);
|
$mHasPoster = $isEdit && !empty($m['poster_key']);
|
||||||
$mHasTicket = $isEdit && !empty($m['ticket_key']);
|
/* 🔴 2026-11-06:`$mHasTicket` 取消。票根概念已并入「观影照片」,
|
||||||
|
旧字段迁完置 NULL,页面不再有第三个分类。 */
|
||||||
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
|
$mPhotos = $isEdit ? (array)($m['photos'] ?? []) : [];
|
||||||
$mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
$mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
||||||
?>
|
?>
|
||||||
@@ -355,21 +356,13 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
|||||||
<div class="mv-row mv-row-media">
|
<div class="mv-row mv-row-media">
|
||||||
<span class="mv-lb no-colon">观影照片</span>
|
<span class="mv-lb no-colon">观影照片</span>
|
||||||
<div class="mv-media-body">
|
<div class="mv-media-body">
|
||||||
<?php if ($mHasTicket || $mPhotos): ?>
|
<?php if ($mPhotos): ?>
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
<?php if ($mHasTicket): /* 历史数据:旧票根(不在 event_photos 里,只展示) */ ?>
|
<?php /* 🔴 2026-11-06:**票根那个特殊格子删掉了**。用户要求只分两类
|
||||||
<?php $mDelForms['ticket'] = 'movies/media_del'; ?>
|
(海报 / 观影照片),旧票根已经一次性迁进 `event_photos`
|
||||||
<figure class="photo-cell">
|
(220 条,position 排最前),现在它就是下面 foreach 里的
|
||||||
<a href="<?= e(media_url((string)$m['ticket_key'])) ?>" data-prev="image"
|
一条普通照片 —— 有 id、能删、能拖进灯箱。
|
||||||
data-src="<?= e(media_url((string)$m['ticket_key'])) ?>"
|
⚠️ 别再把它加回来。留着就等于承认还有第三类。 */ ?>
|
||||||
data-dl="<?= e(media_url((string)$m['ticket_key'], ['dl' => 1])) ?>"
|
|
||||||
data-name="票根">
|
|
||||||
<img src="<?= e(media_url((string)$m['ticket_key'])) ?>" alt="票根">
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit" form="mv-del-ticket"
|
|
||||||
onclick="return confirm('删掉这张旧票根?')">删除</button>
|
|
||||||
</figure>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?>
|
<?php foreach ($mPhotos as $mp): $mpId = (int)$mp['id']; ?>
|
||||||
<figure class="photo-cell">
|
<figure class="photo-cell">
|
||||||
<a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image"
|
<a href="<?= e(media_url((string)$mp['key'])) ?>" data-prev="image"
|
||||||
@@ -450,13 +443,8 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
|||||||
<input type="hidden" name="slot" value="poster">
|
<input type="hidden" name="slot" value="poster">
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($mHasTicket): ?>
|
<?php /* 🔴 2026-11-06:`mv-del-ticket` 这个表单删掉了 —— 票根已并入
|
||||||
<form id="mv-del-ticket" method="post" action="<?= e(url('movies/media_del')) ?>" class="form-outside">
|
`event_photos`,走下面那条按 photo_id 删的通用表单。 */ ?>
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
|
|
||||||
<input type="hidden" name="slot" value="ticket">
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php foreach ($mPhotos as $mp): ?>
|
<?php foreach ($mPhotos as $mp): ?>
|
||||||
<form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post"
|
<form id="mv-del-photo-<?= (int)$mp['id'] ?>" method="post"
|
||||||
action="<?= e(url('movies/photo_del')) ?>" class="form-outside">
|
action="<?= e(url('movies/photo_del')) ?>" class="form-outside">
|
||||||
@@ -467,7 +455,7 @@ $mDelForms = []; // 收集起来,在表单**外面**统一渲染
|
|||||||
<input type="hidden" name="back" value="edit">
|
<input type="hidden" name="back" value="edit">
|
||||||
</form>
|
</form>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php unset($mHasPoster, $mHasTicket, $mPhotos, $mDelForms, $mp, $mpId); ?>
|
<?php unset($mHasPoster, $mPhotos, $mDelForms, $mp, $mpId); ?>
|
||||||
|
|
||||||
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
|
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
|
||||||
<div class="cn-modal" id="cinemaNewModal" hidden>
|
<div class="cn-modal" id="cinemaNewModal" hidden>
|
||||||
|
|||||||
+16
-28
@@ -3,13 +3,14 @@
|
|||||||
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
|
* 观影详情页(v9,展示方式照搬 MovieLog 的 detail.php)。
|
||||||
*
|
*
|
||||||
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。
|
* 五大块:英雄区(左海报右信息)→ 一起看的人 → 电影院信息 → 观影照片(灯箱)→ 评价与评论。
|
||||||
* 海报/票根/照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
|
* 海报/观影照片一律走 media_url() 代理;感受标签只存 key,emoji 与文案查 CATALOG。
|
||||||
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
|
* 访客(只读)看不到编辑/删除/传照片/评价表单 —— 与名册同一口径。
|
||||||
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
|
* 评价人是「登录账号背后的人」,与同行观影人无关(MovieLog 同款规则)。
|
||||||
*/
|
*/
|
||||||
$canWrite = \App\Auth::canWrite();
|
$canWrite = \App\Auth::canWrite();
|
||||||
$hasPoster = !empty($m['poster_key']);
|
$hasPoster = !empty($m['poster_key']);
|
||||||
$hasTicket = !empty($m['ticket_key']);
|
/* 🔴 2026-11-06:`$hasTicket` 取消 —— 票根已并入 `event_photos`,
|
||||||
|
只分「海报 / 观影照片」两类。 */
|
||||||
// 豆瓣元数据(v10):整体 JSON 存 douban_meta,随用随解;解不开就当没有
|
// 豆瓣元数据(v10):整体 JSON 存 douban_meta,随用随解;解不开就当没有
|
||||||
$doubanMeta = [];
|
$doubanMeta = [];
|
||||||
if (!empty($m['douban_meta'])) {
|
if (!empty($m['douban_meta'])) {
|
||||||
@@ -160,7 +161,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<button class="btn" type="button" id="mvEditBtn" aria-haspopup="dialog" aria-expanded="false"
|
<button class="btn" type="button" id="mvEditBtn" aria-haspopup="dialog" aria-expanded="false"
|
||||||
data-href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</button>
|
data-href="<?= e(url('movies/edit', ['id' => (int)$m['id']])) ?>">编辑</button>
|
||||||
<form method="post" action="<?= e(url('movies/destroy')) ?>"
|
<form method="post" action="<?= e(url('movies/destroy')) ?>"
|
||||||
onsubmit="return confirm('删除这条观影记录?海报、票根、照片也会一并删除,不可恢复。')">
|
onsubmit="return confirm('删除这条观影记录?海报、观影照片也会一并删除,不可恢复。')">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
|
||||||
<button type="submit" class="btn btn-del">删除</button>
|
<button type="submit" class="btn btn-del">删除</button>
|
||||||
@@ -258,30 +259,19 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 观影照片(v35:票根 + 现场照片 + 其它相关图片,**不再分类**,合成一块) -->
|
<!-- 观影照片(v38:**只此一类**,票根已并入 event_photos) -->
|
||||||
<?php if ($hasTicket || $m['photos']): ?>
|
<?php if ($m['photos']): ?>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>观影照片 <span class="badge"><?= count($m['photos']) + ($hasTicket ? 1 : 0) ?> 张</span></h3>
|
<h3>观影照片 <span class="badge"><?= count($m['photos']) ?> 张</span></h3>
|
||||||
<div class="gallery-grid">
|
<div class="gallery-grid">
|
||||||
<?php /* ⚠️ 旧票根(movie_details.ticket_key)是历史数据,仍要能看到;
|
<?php /* 🔴 2026-11-06:**票根那个特殊格子删掉了**。用户要求只分两类
|
||||||
归并后它跟 event_photos 里的照片**同一块展示、同一套灯箱**,
|
(海报 / 观影照片);旧 `movie_details.ticket_key` 里的票根
|
||||||
但删除要走它自己的槽位表单(media_del)。 */ ?>
|
已经一次性迁进 `event_photos`(220 条,position 排最前),
|
||||||
<?php if ($hasTicket): $lbIdx = 1; ?>
|
现在它就是下面 foreach 里的普通一张 —— 同一个灯箱、同一个删除路由。
|
||||||
<div class="gallery-item lb-openable" data-lb-index="<?= $lbIdx ?>" role="button" tabindex="0" aria-label="放大查看" title="点击查看大图">
|
⚠️ 别再把它加回来,留着就等于承认还有第三类。
|
||||||
<img src="<?= e(media_url($m['ticket_key'])) ?>" alt="" loading="lazy">
|
⚠️ `$lbIdx` 也因此从 1 起直接算,不再需要 `+ ($hasTicket ? 2 : 1)`。 */ ?>
|
||||||
<?php if ($canWrite): ?>
|
|
||||||
<form method="post" action="<?= e(url('movies/media_del')) ?>"
|
|
||||||
onsubmit="return confirm('删除这张照片?')">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="id" value="<?= (int)$m['id'] ?>">
|
|
||||||
<input type="hidden" name="slot" value="ticket">
|
|
||||||
<button type="submit" class="photo-del" title="删除这张照片">×</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php foreach ($m['photos'] as $i => $ph): ?>
|
<?php foreach ($m['photos'] as $i => $ph): ?>
|
||||||
<div class="gallery-item lb-openable" data-lb-index="<?= $i + ($hasTicket ? 2 : 1) ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
|
<div class="gallery-item lb-openable" data-lb-index="<?= $i + 1 ?>" role="button" tabindex="0" aria-label="放大查看照片" title="点击查看大图">
|
||||||
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
|
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="" loading="lazy">
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<form method="post" action="<?= e(url('movies/photo_del')) ?>"
|
<form method="post" action="<?= e(url('movies/photo_del')) ?>"
|
||||||
@@ -434,7 +424,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($hasPoster || $hasTicket || $m['photos']): ?>
|
<?php if ($hasPoster || $m['photos']): ?>
|
||||||
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
|
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
|
||||||
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
|
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
|
||||||
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
|
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
|
||||||
@@ -454,9 +444,7 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<?php if ($hasPoster): ?>
|
<?php if ($hasPoster): ?>
|
||||||
items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
|
items.push({url: <?= json_encode(media_url((string)$m['poster_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('海报 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($hasTicket): ?>
|
<?php /* 🔴 2026-11-06:票根那条 items.push 删掉了 —— 它已在 `$m['photos']` 里 */ ?>
|
||||||
items.push({url: <?= json_encode(media_url((string)$m['ticket_key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php foreach ($m['photos'] as $ph): ?>
|
<?php foreach ($m['photos'] as $ph): ?>
|
||||||
items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
|
items.push({url: <?= json_encode(media_url((string)$ph['key']), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, cap: <?= json_encode('观影照片 · ' . $m['title'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>});
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
+105
-3
@@ -408,10 +408,40 @@ $imgParts = static function (string $c): array {
|
|||||||
<div id="noteAttForm">
|
<div id="noteAttForm">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
|
||||||
<div class="note-att-drop" id="noteAttDrop">
|
<?php /* 🔴 工单 #21(用户 2026-10-28):「网页端,笔记应该可以同时上传多个附件。」
|
||||||
|
|
||||||
|
实测查清的三件事(别凭印象改):
|
||||||
|
① **多选本来就是好的**:`multiple` + `name="file[]"` + 后端
|
||||||
|
`attach_save_files()` 那段数组分支,一次选 3 个(txt/md/png 混合)
|
||||||
|
真的存了 3 行。这条不用动。
|
||||||
|
② **拖放是假承诺**:上面那句「把文件拖到这里」从来没人接。
|
||||||
|
`app.js` 的 `scan()` 会给每个 file input 挂 `bindZone(input, input)`,
|
||||||
|
但落点绑在 **input 自己**身上 —— 而用户拖的是**外层这个框**,
|
||||||
|
事件发生在框上、不会反向冒泡到 input ⇒ input.files 永远是 0。
|
||||||
|
更糟的是没有 `dragover` 的 preventDefault,浏览器按默认行为
|
||||||
|
**直接打开那个文件**,正在写的笔记连未保存的正文一起丢。
|
||||||
|
③ 传完跳回**阅读态**(见 index.php 的 attach_add),用户看不见刚传的附件,
|
||||||
|
体感就是"没传上"。
|
||||||
|
|
||||||
|
修法:给框加 `data-sd-for="noteAttInput"` —— 这是项目里既有的约定
|
||||||
|
(`bindInput()` 见到它就 `bindZone(box, input)` + `bindBox`),
|
||||||
|
一行属性就能复用成熟实现,不另写一套拖放。
|
||||||
|
|
||||||
|
🔴🔴 `data-sd-for` 里写的是**裸 id,不带 `#`**!
|
||||||
|
取值处是 `querySelectorAll('[data-sd-for="' + input.id + '"]')`,
|
||||||
|
而 `input.id` 是 `noteAttInput`。我第一版写成 `#noteAttInput`,
|
||||||
|
选择器就变成 `[data-sd-for="#noteAttInput"]` —— 一个都匹配不上,
|
||||||
|
拖放照样没人接(判据 ④-a/④-b 直接红)。**照抄 _stage_drop.php:135
|
||||||
|
的写法**(那里是 `data-sd-for="<?= e($sdId) ?>"`,$sdId 是裸 id)。
|
||||||
|
|
||||||
|
⚠️ 不能改成 `data-drop-stage`(暂存式):那套要配合 `upload_stage.php`
|
||||||
|
与 `stage_token[]` 认领,笔记这边是"直接 POST 到 attach_add",两套不能混。
|
||||||
|
⚠️ `bindFileEcho` 只认 `.upbox-msg` / `.upbox-ico`,本框用的是 `.note-att-tip`,
|
||||||
|
所以它插不上手 —— 选中后的即时反馈由下面那段小脚本自己负责。 */ ?>
|
||||||
|
<div class="note-att-drop" id="noteAttDrop" data-sd-for="noteAttInput">
|
||||||
<input type="file" name="file[]" id="noteAttInput" multiple
|
<input type="file" name="file[]" id="noteAttInput" multiple
|
||||||
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
|
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
|
||||||
<span class="note-att-tip">把文件拖到这里,或<label for="noteAttInput">点这里选文件</label></span>
|
<span class="note-att-tip" data-note-att-tip>把文件拖到这里,或<label for="noteAttInput">点这里选文件</label>(可一次选多个)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存",
|
<?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存",
|
||||||
@@ -448,8 +478,80 @@ $imgParts = static function (string $c): array {
|
|||||||
|
|
||||||
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
|
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
|
||||||
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
|
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
|
||||||
<script type="application/json" id="noteSeed"><?= json_encode(
|
<script type="application/json" id="noteSeed"><?php echo json_encode(
|
||||||
$blocks,
|
$blocks,
|
||||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
|
||||||
) ?></script>
|
) ?></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($isEditing): ?>
|
||||||
|
<script>
|
||||||
|
/* ============================================================================
|
||||||
|
笔记附件:选中 / 拖入多个文件后**立刻**给出反馈,并自动提交上传(工单 #21)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
用户原话:「网页端,笔记应该可以同时上传多个附件。」
|
||||||
|
|
||||||
|
拖放本身不在这里做 —— `app.js` 的 `bindInput()` 靠框上的
|
||||||
|
`data-sd-for="noteAttInput"` 已经挂好了 `bindZone`,会把 drop 的文件
|
||||||
|
写进 `input.files` 并派发一次 `change`。**本段只做两件事**:
|
||||||
|
|
||||||
|
① `change` 一来就把提示文案换成「已选择 N 个文件」——
|
||||||
|
否则用户点完/拖完看不到任何变化,会再点一遍(重复选同一批还会重叠)。
|
||||||
|
② 顺手自动提交到 `notes/attach_add`:以前要再点一次「上传所选文件」,
|
||||||
|
多选时这个多余的步骤特别容易漏点,漏点就等于"没传上"。
|
||||||
|
|
||||||
|
⚠️ 为什么用 `requestSubmit(btn)` 而不是 `form.submit()`:
|
||||||
|
`form.submit()` 会**绕过** submit 事件(`note_editor.js` 在 submit 里
|
||||||
|
serialize 正文块),而且不会带上按钮的 `formaction`/`formenctype`——
|
||||||
|
那样这次提交会跑去 `notes/save` 且不是 multipart,文件直接丢。
|
||||||
|
`requestSubmit(btn)` 会带着那颗按钮的 `formaction` 走,语义正确。
|
||||||
|
⚠️ 老浏览器没有 `requestSubmit` 就退回 `form.submit()` 之前先手动把
|
||||||
|
action/enctype 改对——但更简单的做法是让用户点那颗按钮(下面兜底提示)。
|
||||||
|
⚠️ 提交前把 `data-sd-echo`/拖放高亮之类收尾交给 app.js,这里不碰。
|
||||||
|
============================================================================ */
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('noteAttInput');
|
||||||
|
var form = document.getElementById('noteForm');
|
||||||
|
var tip = document.querySelector('[data-note-att-tip]');
|
||||||
|
if (!input || !form || !tip) { return; }
|
||||||
|
|
||||||
|
/* 原始文案要记下来:用户取消选择时要还原(不能变成空字符串) */
|
||||||
|
var original = tip.innerHTML;
|
||||||
|
var btn = document.querySelector('#noteAttForm button[formaction]');
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
var n = input.files ? input.files.length : 0;
|
||||||
|
if (n === 0) { tip.innerHTML = original; return; }
|
||||||
|
var names = Array.prototype.slice.call(input.files, 0, 3)
|
||||||
|
.map(function (f) { return f.name; }).join('、');
|
||||||
|
tip.textContent = '已选择 ' + n + ' 个文件:' + names + (n > 3 ? ' 等' : '')
|
||||||
|
+ ',正在上传…';
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
paint();
|
||||||
|
/* 没选到东西(用户点取消)就别提交了 */
|
||||||
|
if (!input.files || input.files.length === 0) { return; }
|
||||||
|
if (!btn) { return; }
|
||||||
|
/* requestSubmit 会**带上这颗按钮的 formaction** 触发完整提交流程;
|
||||||
|
form.submit() 不会 —— 见上面那条 ⚠️。 */
|
||||||
|
if (typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit(btn);
|
||||||
|
} else {
|
||||||
|
/* 兜底:把 action/enctype 临时改成附件端点再提交。
|
||||||
|
⚠️ 改完**不还原**——页面马上就要跳走了,还原反而可能在
|
||||||
|
"跳转前的最后一刻"被别的代码读到错误状态。 */
|
||||||
|
form.setAttribute('action', btn.getAttribute('formaction'));
|
||||||
|
form.setAttribute('enctype', 'multipart/form-data');
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 上传回来(页面重载)时把提示复位 —— 服务端渲染的是原始文案,
|
||||||
|
不需要额外做;这里只在"从往返缓存恢复"的极端情况下兜一下。 */
|
||||||
|
window.addEventListener('pageshow', function (ev) {
|
||||||
|
if (ev.persisted) { paint(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -366,7 +366,7 @@ $renderGrid = function (array $list) use ($meId, $canMove): void {
|
|||||||
<b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。
|
<b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。
|
||||||
<br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)——
|
<br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)——
|
||||||
谁加进你的名册、谁就会出现在手机通讯录里。
|
谁加进你的名册、谁就会出现在手机通讯录里。
|
||||||
<br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回家书,要改请回到本站来改。
|
<br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回岁月家,要改请回到本站来改。
|
||||||
</p>
|
</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>通讯录地址</label>
|
<label>通讯录地址</label>
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ $shortKind = \App\Birthday::KINDS;
|
|||||||
|
|
||||||
<?php if (empty($p['account_id'])): ?>
|
<?php if (empty($p['account_id'])): ?>
|
||||||
<p class="small muted" style="margin:0 0 14px">
|
<p class="small muted" style="margin:0 0 14px">
|
||||||
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录家书了。
|
开通后,<?= e($p['name']) ?> 就能用手机号和密码登录岁月家了。
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
|
<form method="post" action="<?= e(url('accounts/create')) ?>" class="form-grid">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|||||||
@@ -532,6 +532,47 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
|
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
|
||||||
var addBtn = document.getElementById('resAddBtn');
|
var addBtn = document.getElementById('resAddBtn');
|
||||||
var addCard = document.getElementById('resAddCard');
|
var addCard = document.getElementById('resAddCard');
|
||||||
|
|
||||||
|
/* 🔴 工单 #19(用户 2026-10-28):「资源模块,新建资源,会把之前的附件带进来,
|
||||||
|
就是说,附件那里不是空的。」
|
||||||
|
【根因】`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
|
||||||
|
但附件区**从来没被复位过**:
|
||||||
|
· 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
|
||||||
|
**动态插进 `.stage-list`** 的(assets/js/app.js:668),
|
||||||
|
只有点卡片上那个「×」才会移除;
|
||||||
|
· 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。
|
||||||
|
⇒ 复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**。
|
||||||
|
|
||||||
|
⚠️ 这不只是"看着脏":上传后**没保存就关掉**的那批,暂存目录还在(没被认领),
|
||||||
|
令牌残留在表单里 ⇒ 下次点「确定」时会被 `StagedUpload::claim()` 认领到
|
||||||
|
**新资源**上 —— 实测「新建的资源莫名多了 1 张照片」
|
||||||
|
(见 `_tooling/_b36_res_attach_reset.py` 的 D1)。
|
||||||
|
⚠️ 编辑态**也要清**:`openEdit()` 并不灌附件(已有附件走列表里独立的「附件」弹窗),
|
||||||
|
所以编辑时看到的任何附件都是"上一次没做完的残留" ⇒ 保存时会被挂到这条资源上。
|
||||||
|
⚠️ 为什么"只清 DOM"就够了:卡片在**拖入时**就已经 append 进 `.stage-list`
|
||||||
|
(app.js:600,同步执行),上传完成的回调是在那个**已脱离文档**的节点上
|
||||||
|
改样式、塞令牌,不会再回到表单里 ⇒ 不需要额外加"代/epoch"计数器。
|
||||||
|
⚠️ 除了卡片,还要兜底清 `input[name="stage_token[]"]`:
|
||||||
|
用户看不见令牌,但它才是真正会被提交的东西(#456 同族:DOM 删了、数据还在)。 */
|
||||||
|
function clearStaged() {
|
||||||
|
var form = document.getElementById('resForm');
|
||||||
|
if (!form) { return; }
|
||||||
|
var list = form.querySelector('[data-stage-list]');
|
||||||
|
if (list) {
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll('.stage-cell'), function (c) {
|
||||||
|
if (c.parentNode) { c.parentNode.removeChild(c); }
|
||||||
|
});
|
||||||
|
// 一张不剩就藏回去(模板初始就 hidden,别留一条空边框)
|
||||||
|
list.hidden = true;
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(form.querySelectorAll('input[name="stage_token[]"]'),
|
||||||
|
function (i) {
|
||||||
|
if (i.parentNode) { i.parentNode.removeChild(i); }
|
||||||
|
});
|
||||||
|
var tip = form.querySelector('[data-stage-tip]');
|
||||||
|
if (tip) { tip.textContent = ''; tip.hidden = true; }
|
||||||
|
}
|
||||||
|
|
||||||
function setResOpen(on) {
|
function setResOpen(on) {
|
||||||
if (!addCard) { return; }
|
if (!addCard) { return; }
|
||||||
addCard.hidden = !on;
|
addCard.hidden = !on;
|
||||||
@@ -611,6 +652,10 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
ids = arr.map(function (o) { return o.id; });
|
ids = arr.map(function (o) { return o.id; });
|
||||||
} catch (e) { ids = []; }
|
} catch (e) { ids = []; }
|
||||||
setPickerValues('share_ids[]', ids);
|
setPickerValues('share_ids[]', ids);
|
||||||
|
/* 🔴 工单 #19:编辑态**也要清**暂存区 —— openEdit 并不灌附件(已有附件走
|
||||||
|
列表里独立的「附件」弹窗,见下面的 openPane),所以此刻表单里若还有卡片/令牌,
|
||||||
|
那一定是"上一次拖进来没保存"的残留 ⇒ 不清的话会被挂到**这条资源**上。 */
|
||||||
|
clearStaged();
|
||||||
setResOpen(true);
|
setResOpen(true);
|
||||||
/* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在,
|
/* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在,
|
||||||
没有"共享给谁/挂哪些附件"可说)。 */
|
没有"共享给谁/挂哪些附件"可说)。 */
|
||||||
@@ -666,6 +711,8 @@ $myPerson = (int)\App\Auth::personId();
|
|||||||
if (el) { el.hidden = true; el.removeAttribute('data-res-id'); }
|
if (el) { el.hidden = true; el.removeAttribute('data-res-id'); }
|
||||||
});
|
});
|
||||||
setPickerValues('share_ids[]', []);
|
setPickerValues('share_ids[]', []);
|
||||||
|
/* 🔴 工单 #19:附件也要清 —— 否则新建时带着上一次的附件,见 clearStaged 的注释 */
|
||||||
|
clearStaged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */
|
/* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ $rosterCount = $rosterCount ?? 0;
|
|||||||
<b>我的日历本</b>和<b>我的名册</b>同步过去。
|
<b>我的日历本</b>和<b>我的名册</b>同步过去。
|
||||||
<br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送,
|
<br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送,
|
||||||
用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。
|
用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。
|
||||||
<br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回家书,
|
<br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回岁月家,
|
||||||
要改请回到本站来改。
|
要改请回到本站来改。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
+209
-23
@@ -68,6 +68,12 @@ foreach ($tpCats as $tpCat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$tpDue = (string)($tpT['due_at'] ?? '');
|
$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);
|
$tpDueWord = \App\Task::dueWord($tpDue !== '' ? $tpDue : null);
|
||||||
$tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpDue);
|
$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-status="<?= e(url('tasks/status')) ?>"
|
||||||
data-tk-url-move="<?= e(url('tasks/move')) ?>"
|
data-tk-url-move="<?= e(url('tasks/move')) ?>"
|
||||||
data-tk-url-cmt-del="<?= e(url('tasks/comment_del')) ?>"
|
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 里的按钮动作
|
<?php /* CSRF 放在面板里(见头部第 ① 条):表单各自带一份,JS 里的按钮动作
|
||||||
从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?>
|
从这里取一份 —— 两处同一个值,`csrf_token()` 天然幂等。 */ ?>
|
||||||
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>" data-tk-csrf>
|
<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 /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?>
|
<?php /* ---- 状态:三段平铺,点一下就改(不是下拉 —— 手机上少一次展开) ---- */ ?>
|
||||||
<div class="tk-sec tk-sec-inline">
|
<div class="tk-sec tk-sec-inline">
|
||||||
<span class="tk-sec-lb">状态</span>
|
<span class="tk-sec-lb">状态</span>
|
||||||
@@ -123,28 +149,148 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* ---- 只读的事实(有才显示) ---- */ ?>
|
<?php /* ---- 更多信息:截止 / 负责人 / 备注 / 协作人 ----
|
||||||
<dl class="kv tk-kv">
|
🔴🔴 #82(用户 2026-11-05):「后续内容也和鸿蒙版保持一致,不需要额外信息。
|
||||||
<?php if ($tpDue !== ''): ?>
|
布局顺序也和鸿蒙版一致:1.任务标题 2.状态 3.看板
|
||||||
<div class="kv-row"><dt>截止</dt>
|
4.截止日期、负责人、备注、协作人 5.子任务」
|
||||||
<dd class="<?= $tpLate ? 'kb-late' : '' ?>"><?= e($tpDue) ?><?= $tpDueWord !== '' ? '(' . e($tpDueWord) . ')' : '' ?></dd>
|
|
||||||
|
⇒ 去掉「分类」和「循环」两条 —— 它们属于用户说的"额外信息"
|
||||||
|
(分类是标签、循环是只读事实,鸿蒙端面板里也没有)。
|
||||||
|
⇒ 顺序改成 截止 → 负责人 → 备注 → 协作人(与鸿蒙端逐项对齐)。
|
||||||
|
⚠️ 服务端 `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>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($tpT['assignee_name'])): ?>
|
<div class="tk-attr-row">
|
||||||
<div class="kv-row"><dt>负责人</dt>
|
<span class="tk-attr-lb">负责人</span>
|
||||||
<dd><?= e(person_name(['name' => $tpT['assignee_name'], 'my_nickname' => $tpT['assignee_nick'] ?? ''])) ?></dd>
|
<?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>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($tpCatName !== ''): ?>
|
<div class="tk-attr-row tk-attr-row-top">
|
||||||
<div class="kv-row"><dt>分类</dt><dd><?= e($tpCatName) ?></dd></div>
|
<span class="tk-attr-lb">备注</span>
|
||||||
<?php endif; ?>
|
<?php if ($tpCan): ?>
|
||||||
<?php if (!empty($tpT['repeat_rule'])): ?>
|
<textarea class="input" id="tkp-note" name="note" rows="2"
|
||||||
<div class="kv-row"><dt>循环</dt><dd><?= e(\App\Task::repeatWord((string)$tpT['repeat_rule'])) ?></dd></div>
|
placeholder="补充说明…"><?= e((string)($tpT['note'] ?? '')) ?></textarea>
|
||||||
<?php endif; ?>
|
<?php else: ?>
|
||||||
<?php if (trim((string)($tpT['note'] ?? '')) !== ''): ?>
|
<span><?= trim((string)($tpT['note'] ?? '')) !== ''
|
||||||
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$tpT['note'])) ?></dd></div>
|
? nl2br(e((string)$tpT['note'])) : '无' ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</dl>
|
</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 /* ---- 子任务(子任务自己不再挂子任务,与详情页同一口径) ---- */ ?>
|
||||||
<?php if (!$tpIsChild): ?>
|
<?php if (!$tpIsChild): ?>
|
||||||
@@ -259,12 +405,52 @@ $tpLate = $tpDue !== '' && $tpStatus !== 'done' && \App\Task::isOverdue($tpD
|
|||||||
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
|
<p class="empty-hint">还没有留言。写 @名字 可以点名。</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($tpCan): ?>
|
<?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() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $tpId ?>">
|
<input type="hidden" name="id" value="<?= $tpId ?>">
|
||||||
<input type="hidden" name="ajax_panel" value="1">
|
<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>
|
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>
|
<div class="btn-row"><button class="btn btn-sm" type="submit">发表</button></div>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -171,13 +171,12 @@ $nfEmbed = !empty($nfEmbed);
|
|||||||
</option>
|
</option>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</select>
|
</select>
|
||||||
<span class="tip">在右上角「设置 → 节点类型」里可以增删自定义。</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<?php /* 🔴 #47(2026-11-05):**「当前状态」不再显示**。
|
||||||
<label>当前状态</label>
|
用户:「实际行程、当前状态实际行程,这个地方也不要出现,
|
||||||
<div class="node-stage-ro"><?= e(\App\Trip::stageWord($stage)) ?></div>
|
因为这是没有意义的。我现在本来就是添加的一个实际行程。」
|
||||||
</div>
|
⇒ 阶段由入口决定(计划/实际两颗按钮),编辑时不该再展示。 */ ?>
|
||||||
|
|
||||||
<?php /* ---- 标题 ---- */ ?>
|
<?php /* ---- 标题 ---- */ ?>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
@@ -202,7 +201,6 @@ $nfEmbed = !empty($nfEmbed);
|
|||||||
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
|
<?php $dtName = 'ends_at'; $dtValue = $n['ends_at'] ?? '';
|
||||||
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
|
$dtId = 'nf-e'; $dtRange = 'end'; $dtPlugin = 'antd';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<span class="tip">结束时间可不填;填到几点几分,详情页就显示到几点几分。</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
|
<?php /* ---- 出行方式(TripLog 的 travel_modes)---- */ ?>
|
||||||
@@ -251,7 +249,6 @@ $nfEmbed = !empty($nfEmbed);
|
|||||||
<label for="nf-tripno">车次 / 航班号</label>
|
<label for="nf-tripno">车次 / 航班号</label>
|
||||||
<input class="input" id="nf-tripno" name="trip_no"
|
<input class="input" id="nf-tripno" name="trip_no"
|
||||||
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
|
value="<?= e(old('trip_no', $n['trip_no'] ?? '')) ?>" placeholder="如:G1234、CA1234">
|
||||||
<span class="tip">座位这类同团不同座的信息就不记了。</span>
|
|
||||||
</div>
|
</div>
|
||||||
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
|
<?php /* 线路名与承运方:v24 第 20 批 第 5 条 用户明确「都不要了」——
|
||||||
原话:「添加行程,线路名、承运方,都不要了。」
|
原话:「添加行程,线路名、承运方,都不要了。」
|
||||||
@@ -283,16 +280,15 @@ $nfEmbed = !empty($nfEmbed);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<?php /* 🔴 #47(2026-11-05):**「执行状态」不再由表单选**。
|
||||||
<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>
|
⇒ 状态由 show.php 节点卡片上的动作按钮改(#47 新增的 API)。
|
||||||
<?php endforeach; ?>
|
表单**不提交 status**,服务端 saveNode 的 PATCH 语义会沿用原值
|
||||||
</select>
|
(见 Trip::saveNode 里 `array_key_exists` 的判定)。 */ ?>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="nf-detail">记录</label>
|
<label for="nf-detail">记录</label>
|
||||||
|
|||||||
+86
-13
@@ -36,10 +36,14 @@ foreach ((array)($nodes ?? []) as $n) {
|
|||||||
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
|
if (!isset($bysStage[$sg])) { $sg = 'plan'; }
|
||||||
$bysStage[$sg][] = $n;
|
$bysStage[$sg][] = $n;
|
||||||
}
|
}
|
||||||
|
/* 🔴 #48(2026-11-05):**提示语删掉**(用户:「已走过 —— 也出现在日历与首页
|
||||||
|
『往年今日』,这句话删除」)。不只删这一句 —— 三个分组的提示语一律去掉:
|
||||||
|
组头只留名称和条数,界面更干净。
|
||||||
|
⚠️ 保留数组结构(只用 [0] 名称),改动面最小。 */
|
||||||
$groupMeta = [
|
$groupMeta = [
|
||||||
'plan' => ['计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行'],
|
'plan' => ['计划的行程'],
|
||||||
'actual' => ['实际行程', '已走过 —— 也出现在日历与首页「往年今日」'],
|
'actual' => ['实际行程'],
|
||||||
'skipped' => ['未成行', '这次没去成;不上日程,但在这里留着可查'],
|
'skipped' => ['未成行'],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
@@ -97,7 +101,8 @@ $groupMeta = [
|
|||||||
<div class="card card-pad trip-hero">
|
<div class="card card-pad trip-hero">
|
||||||
<div class="trip-hero-cover">
|
<div class="trip-hero-cover">
|
||||||
<?php if (!empty($t['cover_key'])): ?>
|
<?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: ?>
|
<?php else: ?>
|
||||||
<span class="trip-cover-empty">🧳</span>
|
<span class="trip-cover-empty">🧳</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -228,14 +233,15 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?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; } /* 空分组不显示 */ ?>
|
<?php $list = $bysStage[$sg]; if (!$list) { continue; } /* 空分组不显示 */ ?>
|
||||||
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
|
<div class="card card-pad trip-stage-card stage-<?= e($sg) ?>">
|
||||||
<h2 class="cinema-h2">
|
<h2 class="cinema-h2">
|
||||||
<?= e($groupMeta[$sg][0]) ?>
|
<?= e($groupMeta[$sg][0]) ?>
|
||||||
<span class="group-n"><?= count($list) ?> 段</span>
|
<span class="group-n"><?= count($list) ?> 段</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="stage-hint"><?= e($groupMeta[$sg][1]) ?></p>
|
|
||||||
|
|
||||||
<ol class="node-list">
|
<ol class="node-list">
|
||||||
<?php foreach ($list as $n): ?>
|
<?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) ?>"
|
<li class="node-row status-<?= e((string)$n['status']) ?> stage-<?= e($sg) ?>"
|
||||||
id="node-<?= $nid ?>">
|
id="node-<?= $nid ?>">
|
||||||
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'], $fid)) ?>">
|
<?php /* 🔴 2026-11-05(用户:「明明选的是坐火车,网页版的图标显示的还是一辆汽车」):
|
||||||
<?= e(\App\Trip::typeIcon((string)$n['node_type'], $fid)) ?>
|
左侧图标改成**出行方式优先** —— 坐火车就显 🚄,
|
||||||
|
不再一律显节点类型的 🚗。与端上 `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>
|
</span>
|
||||||
<div class="node-body">
|
<div class="node-body">
|
||||||
<div class="node-head">
|
<div class="node-head">
|
||||||
@@ -285,8 +295,22 @@ foreach ($bysStage as $lst) { $shown += count($lst); }
|
|||||||
|
|
||||||
<?php if ($span !== '日期待补'): ?>
|
<?php if ($span !== '日期待补'): ?>
|
||||||
<div class="node-time">
|
<div class="node-time">
|
||||||
<?= e($span) ?>
|
<?php /* 🔴 #48:**计划时间与实际时间同时显示**。
|
||||||
<?php if ($sg === 'actual'): ?><span class="actual-tag">实际</span><?php endif; ?>
|
用户:「原来计划的时间和现在真正开始的时间都要记录。」
|
||||||
|
⇒ 计划用常规色,实际用主题色并标注「实际」。 */ ?>
|
||||||
|
<?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>
|
</div>
|
||||||
<?php endif; ?>
|
<?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'] ?? ''));
|
$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'] ?? ''));
|
$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 !== ''): ?>
|
<?php if ($navFrom !== ''): ?>
|
||||||
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
|
<a href="<?= e($navFrom) ?>" target="_blank" rel="noopener"
|
||||||
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
|
title="<?= e((string)($n['from_addr'] ?? '')) ?>"><?= e((string)$n['from_place']) ?></a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?= e((string)($n['from_place'] ?? '—')) ?>
|
<span class="route-stop"><?= e((string)($n['from_place'] ?? '—')) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="arrow">→</span>
|
<span class="arrow">→</span>
|
||||||
<?php if ($navTo !== ''): ?>
|
<?php if ($navTo !== ''): ?>
|
||||||
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
|
<a href="<?= e($navTo) ?>" target="_blank" rel="noopener"
|
||||||
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
|
title="<?= e((string)($n['to_addr'] ?? '')) ?>"><?= e((string)$n['to_place']) ?></a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?= e((string)($n['to_place'] ?? '—')) ?>
|
<span class="route-stop"><?= e((string)($n['to_place'] ?? '—')) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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>
|
<p class="node-detail"><?= nl2br(e((string)$n['detail'])) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user